mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 18:57:14 +00:00
0f6e1ae8d7
* fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds The JSON subscription's local SOCKS/HTTP inbounds had no listen address, so every client that runs the profile verbatim bound an unauthenticated proxy on 0.0.0.0, and iOS packet-tunnel clients could not reach it at all (Happ iOS: CONNECTED with zero traffic, same symptom as #6379 — on the same device the mixed inbound also worked once bound to 127.0.0.1). Bind both to loopback, which is what every client's own generated config does. The global subJsonMux was also applied to VLESS outbounds carrying xtls-rprx-vision. XTLS flows do not support mux.cool: Xray answers the mux handshake with "common/mux: unexpected network TCP" and the tunnel passes nothing, on every platform (verified with Happ iOS/Android/macOS, V2Box iOS and desktop Xray 26.6.27 against a 3x-ui 3.7.0 box with per-client traffic counters). Skip the mux block whenever the outbound carries a flow. Refs #6379 * fix(sub): keep XUDP settings when disabling TCP mux on Vision outbounds Clearing the whole mux object also dropped xudpConcurrency, xudpProxyUDP443 and any per-host muxParams override. Xray reads those only under mux.enabled, so set concurrency to -1 instead: TCP mux.cool (which XTLS flows reject) is off, XUDP and the UDP/443 policy stay. The test now decodes each outbound into a fresh map. --------- Co-authored-by: Farhan Zare <farhan.zare@openscreen.com>
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
buildSubscriptionUrls,
|
|
buildShareLinks,
|
|
buildBase64Subscription,
|
|
buildJsonSubscription,
|
|
type SubClient,
|
|
type SubUrlInput,
|
|
} from './subscription';
|
|
import { base64ToText } from './base64';
|
|
import { parseLink } from './links';
|
|
|
|
describe('buildSubscriptionUrls', () => {
|
|
const base: SubUrlInput = {
|
|
scheme: 'http',
|
|
host: 'sub.example.com',
|
|
port: 2096,
|
|
subPath: '/sub/',
|
|
jsonPath: '/json/',
|
|
subId: 'ABC',
|
|
};
|
|
|
|
it('builds the sub and json URLs with the port', () => {
|
|
const u = buildSubscriptionUrls(base);
|
|
expect(u.base64).toBe('http://sub.example.com:2096/sub/ABC');
|
|
expect(u.json).toBe('http://sub.example.com:2096/json/ABC');
|
|
});
|
|
|
|
it('omits the port behind a reverse proxy', () => {
|
|
const u = buildSubscriptionUrls({
|
|
...base,
|
|
scheme: 'https',
|
|
host: 'example.com',
|
|
subPath: '/sub-xxx/',
|
|
behindProxy: true,
|
|
});
|
|
expect(u.base64).toBe('https://example.com/sub-xxx/ABC');
|
|
expect(u.json).toBe('https://example.com/json/ABC');
|
|
});
|
|
|
|
it('normalizes a path missing its slashes', () => {
|
|
const u = buildSubscriptionUrls({ ...base, subPath: 'sub' });
|
|
expect(u.base64).toBe('http://sub.example.com:2096/sub/ABC');
|
|
});
|
|
|
|
it('returns empty strings for an empty subId', () => {
|
|
const u = buildSubscriptionUrls({ ...base, subId: '' });
|
|
expect(u.base64).toBe('');
|
|
expect(u.json).toBe('');
|
|
});
|
|
});
|
|
|
|
const vlessClient: SubClient = {
|
|
protocol: 'vless',
|
|
remark: 'HK-01',
|
|
address: 'a.example.com',
|
|
port: 443,
|
|
id: '11111111-2222-3333-4444-555555555555',
|
|
flow: 'xtls-rprx-vision',
|
|
network: 'tcp',
|
|
security: 'reality',
|
|
sni: 'www.microsoft.com',
|
|
publicKey: 'PBK',
|
|
shortId: 'ab',
|
|
fingerprint: 'chrome',
|
|
};
|
|
|
|
describe('buildShareLinks', () => {
|
|
it('builds a vless link that round-trips through parseLink', () => {
|
|
const [link] = buildShareLinks([vlessClient]);
|
|
const parsed = parseLink(link);
|
|
expect(parsed.protocol).toBe('vless');
|
|
expect(parsed.address).toBe('a.example.com');
|
|
expect(parsed.port).toBe(443);
|
|
expect(parsed.credential).toBe('11111111-2222-3333-4444-555555555555');
|
|
expect(parsed.params.security).toBe('reality');
|
|
expect(parsed.name).toBe('HK-01');
|
|
});
|
|
});
|
|
|
|
describe('buildBase64Subscription', () => {
|
|
it('decodes back to the newline-joined links', () => {
|
|
const trojan: SubClient = {
|
|
protocol: 'trojan',
|
|
remark: 'T',
|
|
address: 'b.example.com',
|
|
port: 443,
|
|
password: 'pw',
|
|
network: 'ws',
|
|
security: 'tls',
|
|
path: '/x',
|
|
sni: 'b.example.com',
|
|
};
|
|
const body = buildBase64Subscription([vlessClient, trojan]);
|
|
const decoded = base64ToText(body);
|
|
expect(decoded.split('\n')).toEqual(buildShareLinks([vlessClient, trojan]));
|
|
});
|
|
|
|
it('returns empty for no clients', () => {
|
|
expect(buildBase64Subscription([])).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('buildJsonSubscription', () => {
|
|
it('emits a single object for one client with a FLAT proxy outbound (settings.id, no vnext)', () => {
|
|
const cfg = JSON.parse(buildJsonSubscription([vlessClient]));
|
|
expect(Array.isArray(cfg)).toBe(false);
|
|
const proxy = cfg.outbounds[0];
|
|
expect(proxy.protocol).toBe('vless');
|
|
expect(proxy.tag).toBe('proxy');
|
|
expect(proxy.settings.id).toBe(vlessClient.id);
|
|
expect('vnext' in proxy.settings).toBe(false);
|
|
expect(proxy.settings.level).toBe(8);
|
|
expect(proxy.settings.flow).toBe('xtls-rprx-vision');
|
|
// the default.json skeleton outbounds are preserved after the proxy
|
|
expect(cfg.outbounds.some((o: { tag: string }) => o.tag === 'direct')).toBe(true);
|
|
expect(cfg.outbounds.some((o: { tag: string }) => o.tag === 'block')).toBe(true);
|
|
// sockopt is stripped from JSON-sub streamSettings
|
|
expect(JSON.stringify(proxy.streamSettings)).not.toContain('sockopt');
|
|
expect(cfg.remarks).toBe('HK-01');
|
|
});
|
|
|
|
it('uses the iOS-compatible SOCKS inbound while preserving the mixed tag and HTTP inbound', () => {
|
|
const cfg = JSON.parse(buildJsonSubscription([vlessClient]));
|
|
const socks = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10808);
|
|
const http = cfg.inbounds.find((inbound: { port: number }) => inbound.port === 10809);
|
|
|
|
expect(socks).toMatchObject({
|
|
listen: '127.0.0.1',
|
|
protocol: 'socks',
|
|
tag: 'mixed',
|
|
settings: { udp: true },
|
|
});
|
|
expect(http).toMatchObject({ listen: '127.0.0.1', protocol: 'http' });
|
|
});
|
|
|
|
it('trojan uses servers[] with a password and no method', () => {
|
|
const trojan: SubClient = {
|
|
protocol: 'trojan',
|
|
remark: 'T',
|
|
address: 'b',
|
|
port: 443,
|
|
password: 'pw',
|
|
network: 'tcp',
|
|
security: 'tls',
|
|
};
|
|
const cfg = JSON.parse(buildJsonSubscription([trojan]));
|
|
const server = cfg.outbounds[0].settings.servers[0];
|
|
expect(server.password).toBe('pw');
|
|
expect('method' in server).toBe(false);
|
|
});
|
|
|
|
it('shadowsocks servers[] include a method', () => {
|
|
const ss: SubClient = {
|
|
protocol: 'ss',
|
|
remark: 'S',
|
|
address: 'c',
|
|
port: 8388,
|
|
password: 'pw',
|
|
method: 'aes-256-gcm',
|
|
};
|
|
const cfg = JSON.parse(buildJsonSubscription([ss]));
|
|
expect(cfg.outbounds[0].protocol).toBe('shadowsocks');
|
|
expect(cfg.outbounds[0].settings.servers[0].method).toBe('aes-256-gcm');
|
|
});
|
|
|
|
it('emits an array for multiple clients', () => {
|
|
const json = buildJsonSubscription([vlessClient, { ...vlessClient, remark: 'HK-02' }]);
|
|
expect(Array.isArray(JSON.parse(json))).toBe(true);
|
|
});
|
|
|
|
it('returns empty for no clients', () => {
|
|
expect(buildJsonSubscription([])).toBe('');
|
|
});
|
|
});
|