mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-04 13:12:07 +03:00
ce221c33d0
* 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>
271 lines
7.5 KiB
TypeScript
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);
|
|
}
|