mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-30 08:59:34 +00:00
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.
This commit is contained in:
@@ -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
frontend/src/test/inbound-from-db.test.ts
Normal file
218
frontend/src/test/inbound-from-db.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user