From 2b4686de99b175580cb3c74d65190be9bc8f6eb4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 20:00:30 +0200 Subject: [PATCH] fix(frontend): inboundFromDb fills Zod defaults for stream + settings Smoke-testing the new inboundFromDb helper surfaced two regressions that the strict lib/xray link generators expose when fed raw DB streamSettings without per-network sub-keys. 1. genVlessLink / genTrojanLink crash on `stream.tcpSettings.header` when streamSettings lacks `tcpSettings` (true for slim list rows and for handcrafted minimal-JSON inbounds). The legacy Inbound.fromJson chain populated TcpStreamSettings via its own constructor; the new helper now does the same by parsing the raw Settings sub-object through the matching Zod schema and merging schema defaults onto whatever the DB stored. 2. genVlessLink writes `encryption=undefined` into the share URL when settings lacks the `encryption: 'none'` literal that vless wire JSON normally carries. Fixed by running raw settings through InboundSettingsSchema.safeParse() to populate per-protocol defaults (encryption, decryption, fallbacks, etc.) the same way the legacy class fromJson chain did. Same pattern applied to security branch (tls/realitySettings). Tests: src/test/inbound-from-db.test.ts covers - JSON-string / object / empty settings coercion - genInboundLinks vless (TCP/none, with encryption=none) - genWireguardConfigs + genWireguardLinks peer fanout - genAllLinks trojan with TLS sub-defaults applied - protocol-capability helpers with raw shapes - getInboundClients across vless/SS-single/non-client protocols 296/296 pass. --- frontend/src/lib/xray/inbound-from-db.ts | 87 ++++++++- frontend/src/test/inbound-from-db.test.ts | 218 ++++++++++++++++++++++ 2 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test/inbound-from-db.test.ts diff --git a/frontend/src/lib/xray/inbound-from-db.ts b/frontend/src/lib/xray/inbound-from-db.ts index 56db32f5..3e01d22b 100644 --- a/frontend/src/lib/xray/inbound-from-db.ts +++ b/frontend/src/lib/xray/inbound-from-db.ts @@ -1,4 +1,18 @@ import type { Inbound } from '@/schemas/api/inbound'; +import { InboundSettingsSchema } from '@/schemas/protocols/inbound'; +import { + GrpcStreamSettingsSchema, + HttpUpgradeStreamSettingsSchema, + HysteriaStreamSettingsSchema, + KcpStreamSettingsSchema, + TcpStreamSettingsSchema, + WsStreamSettingsSchema, + XHttpStreamSettingsSchema, +} from '@/schemas/protocols/stream'; +import { + RealityStreamSettingsSchema, + TlsStreamSettingsSchema, +} from '@/schemas/protocols/security'; import { coerceInboundJsonField } from '@/models/dbinbound'; export interface DbInboundLike { @@ -17,10 +31,79 @@ export interface DbInboundLike { total?: number; } +const NETWORK_KEY_MAP = { + tcp: 'tcpSettings', + kcp: 'kcpSettings', + ws: 'wsSettings', + grpc: 'grpcSettings', + httpupgrade: 'httpupgradeSettings', + xhttp: 'xhttpSettings', + hysteria: 'hysteriaSettings', +} as const; + +type SchemaWithParse = { safeParse: (v: unknown) => { success: boolean; data?: unknown } }; + +function parseOrDefault(schema: SchemaWithParse, value: unknown): unknown { + const parsed = schema.safeParse(value ?? {}); + if (parsed.success) return parsed.data; + const fallback = schema.safeParse({}); + return fallback.success ? fallback.data : value; +} + +function networkSchemaFor(network: string): SchemaWithParse | null { + switch (network) { + case 'tcp': return TcpStreamSettingsSchema; + case 'kcp': return KcpStreamSettingsSchema; + case 'ws': return WsStreamSettingsSchema; + case 'grpc': return GrpcStreamSettingsSchema; + case 'httpupgrade': return HttpUpgradeStreamSettingsSchema; + case 'xhttp': return XHttpStreamSettingsSchema; + case 'hysteria': return HysteriaStreamSettingsSchema; + default: return null; + } +} + +function securitySchemaFor(security: string): { key: string; schema: SchemaWithParse } | null { + switch (security) { + case 'tls': return { key: 'tlsSettings', schema: TlsStreamSettingsSchema }; + case 'reality': return { key: 'realitySettings', schema: RealityStreamSettingsSchema }; + default: return null; + } +} + +function fillStreamDefaults(stream: Record): Record { + const network = (stream.network as string | undefined) ?? 'tcp'; + const security = (stream.security as string | undefined) ?? 'none'; + const out: Record = { ...stream, network, security }; + const subKey = NETWORK_KEY_MAP[network as keyof typeof NETWORK_KEY_MAP]; + const netSchema = networkSchemaFor(network); + if (subKey && netSchema) { + out[subKey] = parseOrDefault(netSchema, out[subKey]); + } + const sec = securitySchemaFor(security); + if (sec) { + out[sec.key] = parseOrDefault(sec.schema, out[sec.key]); + } + return out; +} + +function fillProtocolSettingsDefaults(protocol: string, settings: Record): Record { + const parsed = InboundSettingsSchema.safeParse({ protocol, settings }); + if (parsed.success) { + const tagged = parsed.data as { settings: Record }; + return { ...tagged.settings }; + } + return settings; +} + export function inboundFromDb(raw: DbInboundLike): Inbound { - const settings = coerceInboundJsonField(raw.settings); - const streamSettings = coerceInboundJsonField(raw.streamSettings); + const rawSettings = coerceInboundJsonField(raw.settings); + const settings = fillProtocolSettingsDefaults(raw.protocol, rawSettings); + const streamSettingsRaw = coerceInboundJsonField(raw.streamSettings); const sniffing = coerceInboundJsonField(raw.sniffing); + const streamSettings = Object.keys(streamSettingsRaw).length === 0 + ? streamSettingsRaw + : fillStreamDefaults(streamSettingsRaw); return { protocol: raw.protocol, port: raw.port, diff --git a/frontend/src/test/inbound-from-db.test.ts b/frontend/src/test/inbound-from-db.test.ts new file mode 100644 index 00000000..6c8b11ec --- /dev/null +++ b/frontend/src/test/inbound-from-db.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { inboundFromDb } from '@/lib/xray/inbound-from-db'; +import { + genAllLinks, + genInboundLinks, + genWireguardConfigs, + genWireguardLinks, + getInboundClients, +} from '@/lib/xray/inbound-link'; +import { + canEnableTlsFlow, + isSS2022, + isSSMultiUser, +} from '@/lib/xray/protocol-capabilities'; + +const FALLBACK_HOST = 'panel.example.test'; + +const BASE_DB_FIELDS = { + port: 12345, + listen: '', + tag: '', + remark: 'unit', + enable: true, + expiryTime: 0, + up: 0, + down: 0, + total: 0, + sniffing: '', +}; + +describe('inboundFromDb', () => { + it('coerces JSON-string settings into a parsed object', () => { + const raw = { + ...BASE_DB_FIELDS, + protocol: 'vless', + settings: JSON.stringify({ + clients: [{ id: 'abc', email: 'a@test', flow: '' }], + decryption: 'none', + }), + streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }), + }; + const inbound = inboundFromDb(raw); + expect(inbound.protocol).toBe('vless'); + expect(inbound.port).toBe(12345); + expect((inbound.settings as { decryption?: string }).decryption).toBe('none'); + expect((inbound.streamSettings as { network?: string })?.network).toBe('tcp'); + }); + + it('fills schema defaults onto partial object settings', () => { + const settings = { clients: [], decryption: 'none' }; + const raw = { + ...BASE_DB_FIELDS, + protocol: 'vless', + settings, + streamSettings: { network: 'ws', security: 'tls' }, + }; + const inbound = inboundFromDb(raw); + // encryption/fallbacks defaulted by schema, original settings ref not preserved + expect(inbound.settings).not.toBe(settings); + expect((inbound.settings as { encryption?: string }).encryption).toBe('none'); + expect((inbound.streamSettings as { security?: string })?.security).toBe('tls'); + }); + + it('returns schema-default settings for missing/empty fields without throwing', () => { + const raw = { + ...BASE_DB_FIELDS, + protocol: 'http', + settings: '', + streamSettings: '', + sniffing: '', + }; + const inbound = inboundFromDb(raw); + // http settings has its own schema defaults (accounts: [], allowTransparent: false) + expect(inbound.settings).toEqual(expect.objectContaining({ accounts: [] })); + expect(inbound.streamSettings).toEqual({}); + expect(inbound.sniffing).toEqual({}); + }); + + it('feeds genInboundLinks for vless without throwing', () => { + const raw = { + ...BASE_DB_FIELDS, + protocol: 'vless', + settings: { + clients: [{ id: '8c14d6f7-2e3b-4a91-9d24-3f7a6b8c1e02', email: 'alice@test', flow: '' }], + decryption: 'none', + }, + streamSettings: { network: 'tcp', security: 'none' }, + }; + const inbound = inboundFromDb(raw); + const links = genInboundLinks({ + inbound, + remark: 'unit', + fallbackHostname: FALLBACK_HOST, + }); + expect(links).toContain('vless://'); + expect(links).toContain('alice%40test'); + expect(links).toContain('encryption=none'); + }); + + it('feeds genWireguardConfigs + genWireguardLinks for wireguard peers', () => { + const raw = { + ...BASE_DB_FIELDS, + protocol: 'wireguard', + settings: { + mtu: 1420, + secretKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=', + peers: [ + { + privateKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=', + publicKey: 'DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=', + allowedIPs: ['10.0.0.2/32'], + keepAlive: 25, + }, + ], + noKernelTun: false, + }, + streamSettings: '', + }; + const inbound = inboundFromDb(raw); + const configs = genWireguardConfigs({ inbound, remark: 'wg', fallbackHostname: FALLBACK_HOST }); + expect(configs).toContain('[Interface]'); + expect(configs).toContain('[Peer]'); + const links = genWireguardLinks({ inbound, remark: 'wg', fallbackHostname: FALLBACK_HOST }); + expect(links).toMatch(/^wireguard:\/\//); + }); + + it('feeds genAllLinks per client', () => { + const raw = { + ...BASE_DB_FIELDS, + protocol: 'trojan', + settings: { + clients: [ + { password: 'pw1', email: 'one@test' }, + { password: 'pw2', email: 'two@test' }, + ], + }, + streamSettings: { network: 'tcp', security: 'tls', tlsSettings: { serverName: 'example.test' } }, + }; + const inbound = inboundFromDb(raw); + const entries = genAllLinks({ + inbound, + remark: 'trojan', + client: { password: 'pw1', email: 'one@test' }, + fallbackHostname: FALLBACK_HOST, + }); + expect(entries.length).toBeGreaterThan(0); + expect(entries[0].link).toContain('trojan://'); + }); +}); + +describe('protocol-capability helpers with raw coerced shapes', () => { + it('isSSMultiUser returns true for legacy SS methods', () => { + expect(isSSMultiUser({ protocol: 'shadowsocks', settings: { method: 'aes-256-gcm' } })).toBe(true); + expect(isSSMultiUser({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } })).toBe(true); + }); + + it('isSSMultiUser returns false for single-user blake3-chacha20 method', () => { + expect(isSSMultiUser({ + protocol: 'shadowsocks', + settings: { method: '2022-blake3-chacha20-poly1305' }, + })).toBe(false); + }); + + it('isSS2022 detects 2022-blake3 family', () => { + expect(isSS2022({ protocol: 'shadowsocks', settings: { method: '2022-blake3-aes-128-gcm' } })).toBe(true); + expect(isSS2022({ protocol: 'shadowsocks', settings: { method: 'aes-256-gcm' } })).toBe(false); + }); + + it('canEnableTlsFlow gates on vless + tcp + tls/reality', () => { + expect(canEnableTlsFlow({ + protocol: 'vless', + streamSettings: { network: 'tcp', security: 'tls' }, + })).toBe(true); + expect(canEnableTlsFlow({ + protocol: 'vless', + streamSettings: { network: 'ws', security: 'tls' }, + })).toBe(false); + expect(canEnableTlsFlow({ + protocol: 'vmess', + streamSettings: { network: 'tcp', security: 'tls' }, + })).toBe(false); + }); +}); + +describe('getInboundClients with schema-shaped inbound', () => { + it('returns clients array for vless/vmess/trojan/hysteria', () => { + const inbound = inboundFromDb({ + ...BASE_DB_FIELDS, + protocol: 'vless', + settings: { clients: [{ id: 'x', email: 'e@test' }], decryption: 'none' }, + streamSettings: { network: 'tcp', security: 'none' }, + }); + expect(getInboundClients(inbound)).toHaveLength(1); + }); + + it('returns null for SS single-user', () => { + const inbound = inboundFromDb({ + ...BASE_DB_FIELDS, + protocol: 'shadowsocks', + settings: { method: '2022-blake3-chacha20-poly1305', password: 'pw', clients: [] }, + streamSettings: { network: 'tcp', security: 'none' }, + }); + expect(getInboundClients(inbound)).toBeNull(); + }); + + it('returns null for non-client protocols (http/mixed/tun/tunnel)', () => { + for (const protocol of ['http', 'mixed', 'tun', 'tunnel']) { + const inbound = inboundFromDb({ + ...BASE_DB_FIELDS, + protocol, + settings: {}, + streamSettings: '', + }); + expect(getInboundClients(inbound)).toBeNull(); + } + }); +});