1
0
Эх сурвалжийг харах

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
   <network>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.
MHSanaei 1 өдөр өмнө
parent
commit
2b4686de99

+ 85 - 2
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<string, unknown>): Record<string, unknown> {
+  const network = (stream.network as string | undefined) ?? 'tcp';
+  const security = (stream.security as string | undefined) ?? 'none';
+  const out: Record<string, unknown> = { ...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<string, unknown>): Record<string, unknown> {
+  const parsed = InboundSettingsSchema.safeParse({ protocol, settings });
+  if (parsed.success) {
+    const tagged = parsed.data as { settings: Record<string, unknown> };
+    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,

+ 218 - 0
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();
+    }
+  });
+});