Files
3x-ui/docs/lib/xray/subscription.ts
T
libmur-dev ce221c33d0 fix(sub): carry REALITY ML-KEM hint in VLESS links (#6712)
* fix(sub): carry REALITY ML-KEM hint in VLESS links

Keep raw share links in parity with Clash subscriptions for Xray 26.9.8+. Preserve the URI hint through Go and frontend imports, expose it in the outbound editor, and update the documentation tooling.

* fix(link): accept REALITY ML-KEM boolean aliases

* test(frontend): isolate Happ preset notifications

* fix(link): keep the ML-KEM hint out of Xray REALITY settings

support-x25519mlkem768 is a Mihomo reality-opts option; xray-core's
REALITYConfig (infra/conf/transport_security.go) has no such field and its
JSON loader drops unknown keys silently. The PR also stored it as
realitySettings.supportX25519Mlkem768 in Xray outbounds (form switch, Go and
TS link import, docs outbound builders) and as an inbound settings default
that is stripped before Xray and read by no link generator. The outbound
switch therefore did nothing, and imported links carried a dead key into the
JSON subscription.

The share-link hint itself stays: Go, frontend and docs still emit
support-x25519mlkem768=true on VLESS REALITY links and drop it on a TLS host
override.

---------

Co-authored-by: libmur-dev <333915961+libmur-dev@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-02 15:44:41 +02:00

271 lines
7.5 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
supportX25519Mlkem768?: boolean; // reality client compatibility
}
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;
if (c.security === 'reality' && c.publicKey && c.supportX25519Mlkem768 !== false) {
p['support-x25519mlkem768'] = 'true';
}
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);
}