Files
3x-ui/docs/lib/xray/subscription.ts
T
MHSanaei 9b91f0f42e docs: vendor the documentation site into the monorepo
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.
2026-07-07 23:07:14 +02:00

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