mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 14:16:07 +00:00
9b91f0f42e
Fold the standalone 3x-ui-docs project (Next.js 16 + Fumadocs, deployed to docs.sanaei.dev) into docs/ so the panel and its documentation share a single source of truth, the way sing-box keeps its docs in-tree. The old repo becomes redundant and can be retired. - Import the full site under docs/ (app, components, content, lib, public, scripts, config). The self-contained pnpm project sits alongside the existing engineering notes with no filename collisions. - Re-point "Edit on GitHub" links from MHSanaei/3x-ui-docs to this repo's docs/content/docs path (docs/lib/shared.ts, docs/app/.../page.tsx). - Add docs-ci.yml and docs-deploy.yml under .github/workflows/, scoped to docs/** and run with working-directory: docs, since GitHub only runs workflows from the repo-root .github/. deploy-static.yml's GitHub Pages publish (CNAME docs.sanaei.dev) carries over unchanged. Follow-up (outside this commit): attach the docs.sanaei.dev custom domain to this repository's Pages (or set the Vercel project's root directory to docs), confirm the site is live from the monorepo, then delete MHSanaei/3x-ui-docs.
250 lines
7.0 KiB
TypeScript
250 lines
7.0 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: [
|
|
{
|
|
port: 10808,
|
|
protocol: 'mixed',
|
|
settings: { auth: 'noauth', udp: true, userLevel: 8 },
|
|
sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
|
|
tag: 'mixed',
|
|
},
|
|
{ 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,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|