Files
3x-ui/docs/lib/xray/subscription.test.ts
T
Farhan Zare 0f6e1ae8d7 fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds (#6418)
* 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>
2026-09-05 20:44:48 +02:00

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('');
});
});