Files
3x-ui/docs/lib/xray/subscription.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

267 lines
7.3 KiB
TypeScript

// Pure builders for 3x-ui's subscription server: the subscription URLs plus
// previews of the two body formats — Base64 (newline-joined share links,
// standard base64) and JSON (Xray client config, one per client). Grounded in
// internal/sub/{controller,build_urls_test}.go, json_service.go, default.json.
// Reuses links.ts (share-link builders), base64.ts, and outbounds.ts
// (buildStreamSettings). No React/DOM imports.
import { textToBase64 } from './base64';
import { buildVless, buildVmess, buildTrojan, buildShadowsocks } from './links';
import { buildStreamSettings, type Network, type Security } from './outbounds';
export interface SubUrlInput {
scheme: 'http' | 'https';
host: string;
port: number;
subPath: string; // e.g. '/sub/'
jsonPath: string; // e.g. '/json/'
subId: string;
/** When behind a reverse proxy the public URL omits the sub-server port. */
behindProxy?: boolean;
}
export interface SubUrls {
base64: string;
json: string;
}
export interface SubClient {
protocol: 'vless' | 'vmess' | 'trojan' | 'ss';
remark: string;
address: string;
port: number;
// credentials
id?: string; // vless / vmess uuid
password?: string; // trojan / ss
method?: string; // ss cipher
flow?: string; // vless
encryption?: string; // vless server encryption, default 'none'
vmessSecurity?: string; // vmess scy, default 'auto'
// stream (subset, mirrored into share-link params + JSON streamSettings)
network?: Network;
security?: Security;
sni?: string;
fingerprint?: string;
path?: string;
host?: string;
serviceName?: string;
publicKey?: string; // reality
shortId?: string; // reality
}
function normPath(p: string): string {
let s = p.trim();
if (!s.startsWith('/')) s = `/${s}`;
if (!s.endsWith('/')) s = `${s}/`;
return s;
}
export function buildSubscriptionUrls(i: SubUrlInput): SubUrls {
if (!i.subId) return { base64: '', json: '' };
const origin = i.behindProxy ? `${i.scheme}://${i.host}` : `${i.scheme}://${i.host}:${i.port}`;
return {
base64: `${origin}${normPath(i.subPath)}${i.subId}`,
json: `${origin}${normPath(i.jsonPath)}${i.subId}`,
};
}
function streamParams(c: SubClient): Record<string, string> {
const p: Record<string, string> = {
type: c.network ?? 'tcp',
security: c.security ?? 'none',
};
if (c.sni) p.sni = c.sni;
if (c.fingerprint) p.fp = c.fingerprint;
if (c.path) p.path = c.path;
if (c.host) p.host = c.host;
if (c.serviceName) p.serviceName = c.serviceName;
if (c.publicKey) p.pbk = c.publicKey;
if (c.shortId) p.sid = c.shortId;
return p;
}
function shareLink(c: SubClient): string {
switch (c.protocol) {
case 'vless': {
const params = streamParams(c);
if (c.flow) params.flow = c.flow;
return buildVless({
credential: c.id ?? '',
address: c.address,
port: c.port,
name: c.remark,
params,
});
}
case 'trojan':
return buildTrojan({
credential: c.password ?? '',
address: c.address,
port: c.port,
name: c.remark,
params: streamParams(c),
});
case 'vmess':
return buildVmess({
ps: c.remark,
add: c.address,
port: c.port,
id: c.id ?? '',
scy: c.vmessSecurity || 'auto',
net: c.network ?? 'tcp',
tls: c.security === 'tls' ? 'tls' : '',
sni: c.sni ?? '',
host: c.host ?? '',
path: c.path ?? '',
});
case 'ss':
return buildShadowsocks({
method: c.method || '',
password: c.password ?? '',
address: c.address,
port: c.port,
name: c.remark,
});
}
}
export function buildShareLinks(clients: SubClient[]): string[] {
return clients.map(shareLink);
}
export function buildBase64Subscription(clients: SubClient[]): string {
if (clients.length === 0) return '';
return textToBase64(buildShareLinks(clients).join('\n'));
}
// The non-outbound skeleton of internal/sub/default.json. A factory so every
// call returns a fresh object (pure, no shared mutation).
function subJsonSkeleton(): Record<string, unknown> {
return {
dns: {
tag: 'dns_out',
queryStrategy: 'UseIP',
servers: [{ address: '8.8.8.8', skipFallback: false }],
},
inbounds: [
{
listen: '127.0.0.1',
port: 10808,
protocol: 'socks',
settings: { auth: 'noauth', udp: true, userLevel: 8 },
sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
tag: 'mixed',
},
{
listen: '127.0.0.1',
port: 10809,
protocol: 'http',
settings: { userLevel: 8 },
tag: 'http',
},
],
log: { loglevel: 'warning' },
policy: {
levels: { '8': { connIdle: 300, downlinkOnly: 1, handshake: 4, uplinkOnly: 1 } },
system: { statsOutboundUplink: true, statsOutboundDownlink: true },
},
routing: {
domainStrategy: 'AsIs',
rules: [{ type: 'field', network: 'tcp,udp', outboundTag: 'proxy' }],
},
stats: {},
};
}
function skeletonOutbounds(): Record<string, unknown>[] {
return [
{
tag: 'direct',
protocol: 'freedom',
settings: { domainStrategy: 'AsIs', redirect: '', noises: [] },
},
{ tag: 'block', protocol: 'blackhole', settings: { response: { type: 'http' } } },
];
}
function proxyOutbound(c: SubClient): Record<string, unknown> {
const streamSettings = buildStreamSettings({
network: c.network ?? 'tcp',
security: c.security ?? 'none',
sni: c.sni,
fingerprint: c.fingerprint,
path: c.path,
host: c.host,
serviceName: c.serviceName,
publicKey: c.publicKey,
shortId: c.shortId,
});
let settings: Record<string, unknown>;
switch (c.protocol) {
case 'vless': {
const s: Record<string, unknown> = {
address: c.address,
port: c.port,
id: c.id ?? '',
encryption: c.encryption || 'none',
level: 8,
};
if (c.flow) s.flow = c.flow;
settings = s;
break;
}
case 'vmess':
settings = {
address: c.address,
port: c.port,
id: c.id ?? '',
security: c.vmessSecurity || 'auto',
level: 8,
};
break;
case 'trojan':
settings = {
servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }],
};
break;
case 'ss':
settings = {
servers: [
{
address: c.address,
port: c.port,
password: c.password ?? '',
level: 8,
method: c.method || '',
},
],
};
break;
}
return {
protocol: c.protocol === 'ss' ? 'shadowsocks' : c.protocol,
tag: 'proxy',
streamSettings,
settings,
};
}
// Mirrors the one-document-per-client model only; the panel also emits
// balancer documents (sub_balancers) that are intentionally out of scope here.
function jsonConfig(c: SubClient): Record<string, unknown> {
return {
remarks: c.remark,
...subJsonSkeleton(),
outbounds: [proxyOutbound(c), ...skeletonOutbounds()],
};
}
export function buildJsonSubscription(clients: SubClient[]): string {
if (clients.length === 0) return '';
const configs = clients.map(jsonConfig);
// 3x-ui returns a single object for one client, an array for several.
return JSON.stringify(configs.length === 1 ? configs[0] : configs, null, 2);
}