Files
3x-ui/frontend/src/test/inbound-from-db.test.ts
T
Rouzbeh† c7a76e9626 fix: enable XTLS vision flow for VLESS+XHTTP+vlessenc in UI and share links (#5157) (#5185)
* fix: enable XTLS vision flow for VLESS+XHTTP+vlessenc in UI and share links (#5157)

* fix: enable xtls-rprx-vision flow for VLESS XHTTP with vlessenc encryption (#5157)

The flow selector was hidden and the vless:// link omitted flow= because:
1. The backend gate (inboundCanEnableTlsFlow) only accepted tcp+tls/reality.
2. The PR #5185 frontend check used `encryption === 'vlessenc'`, which never
   matches — the stored value is a generated ML-KEM dotted string, not the CLI
   subcommand name.

Fix: extend inboundCanEnableTlsFlow to also return true for XHTTP when a
non-none vlessenc encryption/decryption value is present. Update all three
call-sites (inbound.go TlsFlowCapable field, client_crud.go clientWithInboundFlow,
inbound_clients.go copy-flow path) and the sub/service.go link generator.
Scope is XHTTP-only: TCP without tls/reality is intentionally excluded.

Add inbound_protocol_test.go covering the new and existing gate combinations,
extend client_flow_isolation_test.go with xhttp+vlessenc cases, and add
frontend tests for canEnableTlsFlow with real ML-KEM key values.

---------

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 12:04:02 +02:00

247 lines
8.3 KiB
TypeScript

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('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);
});
it('canEnableTlsFlow allows vless + xhttp when vlessenc encryption is set', () => {
const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
const dec = 'mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E';
// XHTTP + a real (generated) encryption value → Vision flow allowed.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { encryption: enc },
streamSettings: { network: 'xhttp', security: 'none' },
})).toBe(true);
// decryption alone (server-side value) is enough on XHTTP.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: 'none' },
streamSettings: { network: 'xhttp', security: 'none' },
})).toBe(true);
// No encryption → stays gated off.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { encryption: 'none' },
streamSettings: { network: 'xhttp', security: 'none' },
})).toBe(false);
// vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable.
expect(canEnableTlsFlow({
protocol: 'vless',
settings: { decryption: dec, encryption: enc },
streamSettings: { network: 'tcp', security: 'none' },
})).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();
}
});
});