subscription.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. // Pure builders for 3x-ui's subscription server: the subscription URLs plus
  2. // previews of the two body formats — Base64 (newline-joined share links,
  3. // standard base64) and JSON (Xray client config, one per client). Grounded in
  4. // internal/sub/{controller,build_urls_test}.go, json_service.go, default.json.
  5. // Reuses links.ts (share-link builders), base64.ts, and outbounds.ts
  6. // (buildStreamSettings). No React/DOM imports.
  7. import { textToBase64 } from './base64';
  8. import { buildVless, buildVmess, buildTrojan, buildShadowsocks } from './links';
  9. import { buildStreamSettings, type Network, type Security } from './outbounds';
  10. export interface SubUrlInput {
  11. scheme: 'http' | 'https';
  12. host: string;
  13. port: number;
  14. subPath: string; // e.g. '/sub/'
  15. jsonPath: string; // e.g. '/json/'
  16. subId: string;
  17. /** When behind a reverse proxy the public URL omits the sub-server port. */
  18. behindProxy?: boolean;
  19. }
  20. export interface SubUrls {
  21. base64: string;
  22. json: string;
  23. }
  24. export interface SubClient {
  25. protocol: 'vless' | 'vmess' | 'trojan' | 'ss';
  26. remark: string;
  27. address: string;
  28. port: number;
  29. // credentials
  30. id?: string; // vless / vmess uuid
  31. password?: string; // trojan / ss
  32. method?: string; // ss cipher
  33. flow?: string; // vless
  34. encryption?: string; // vless server encryption, default 'none'
  35. vmessSecurity?: string; // vmess scy, default 'auto'
  36. // stream (subset, mirrored into share-link params + JSON streamSettings)
  37. network?: Network;
  38. security?: Security;
  39. sni?: string;
  40. fingerprint?: string;
  41. path?: string;
  42. host?: string;
  43. serviceName?: string;
  44. publicKey?: string; // reality
  45. shortId?: string; // reality
  46. supportX25519Mlkem768?: boolean; // reality client compatibility
  47. }
  48. function normPath(p: string): string {
  49. let s = p.trim();
  50. if (!s.startsWith('/')) s = `/${s}`;
  51. if (!s.endsWith('/')) s = `${s}/`;
  52. return s;
  53. }
  54. export function buildSubscriptionUrls(i: SubUrlInput): SubUrls {
  55. if (!i.subId) return { base64: '', json: '' };
  56. const origin = i.behindProxy ? `${i.scheme}://${i.host}` : `${i.scheme}://${i.host}:${i.port}`;
  57. return {
  58. base64: `${origin}${normPath(i.subPath)}${i.subId}`,
  59. json: `${origin}${normPath(i.jsonPath)}${i.subId}`,
  60. };
  61. }
  62. function streamParams(c: SubClient): Record<string, string> {
  63. const p: Record<string, string> = {
  64. type: c.network ?? 'tcp',
  65. security: c.security ?? 'none',
  66. };
  67. if (c.sni) p.sni = c.sni;
  68. if (c.fingerprint) p.fp = c.fingerprint;
  69. if (c.path) p.path = c.path;
  70. if (c.host) p.host = c.host;
  71. if (c.serviceName) p.serviceName = c.serviceName;
  72. if (c.publicKey) p.pbk = c.publicKey;
  73. if (c.shortId) p.sid = c.shortId;
  74. if (c.security === 'reality' && c.publicKey && c.supportX25519Mlkem768 !== false) {
  75. p['support-x25519mlkem768'] = 'true';
  76. }
  77. return p;
  78. }
  79. function shareLink(c: SubClient): string {
  80. switch (c.protocol) {
  81. case 'vless': {
  82. const params = streamParams(c);
  83. if (c.flow) params.flow = c.flow;
  84. return buildVless({
  85. credential: c.id ?? '',
  86. address: c.address,
  87. port: c.port,
  88. name: c.remark,
  89. params,
  90. });
  91. }
  92. case 'trojan':
  93. return buildTrojan({
  94. credential: c.password ?? '',
  95. address: c.address,
  96. port: c.port,
  97. name: c.remark,
  98. params: streamParams(c),
  99. });
  100. case 'vmess':
  101. return buildVmess({
  102. ps: c.remark,
  103. add: c.address,
  104. port: c.port,
  105. id: c.id ?? '',
  106. scy: c.vmessSecurity || 'auto',
  107. net: c.network ?? 'tcp',
  108. tls: c.security === 'tls' ? 'tls' : '',
  109. sni: c.sni ?? '',
  110. host: c.host ?? '',
  111. path: c.path ?? '',
  112. });
  113. case 'ss':
  114. return buildShadowsocks({
  115. method: c.method || '',
  116. password: c.password ?? '',
  117. address: c.address,
  118. port: c.port,
  119. name: c.remark,
  120. });
  121. }
  122. }
  123. export function buildShareLinks(clients: SubClient[]): string[] {
  124. return clients.map(shareLink);
  125. }
  126. export function buildBase64Subscription(clients: SubClient[]): string {
  127. if (clients.length === 0) return '';
  128. return textToBase64(buildShareLinks(clients).join('\n'));
  129. }
  130. // The non-outbound skeleton of internal/sub/default.json. A factory so every
  131. // call returns a fresh object (pure, no shared mutation).
  132. function subJsonSkeleton(): Record<string, unknown> {
  133. return {
  134. dns: {
  135. tag: 'dns_out',
  136. queryStrategy: 'UseIP',
  137. servers: [{ address: '8.8.8.8', skipFallback: false }],
  138. },
  139. inbounds: [
  140. {
  141. listen: '127.0.0.1',
  142. port: 10808,
  143. protocol: 'socks',
  144. settings: { auth: 'noauth', udp: true, userLevel: 8 },
  145. sniffing: { destOverride: ['http', 'tls', 'quic', 'fakedns'], enabled: true },
  146. tag: 'mixed',
  147. },
  148. {
  149. listen: '127.0.0.1',
  150. port: 10809,
  151. protocol: 'http',
  152. settings: { userLevel: 8 },
  153. tag: 'http',
  154. },
  155. ],
  156. log: { loglevel: 'warning' },
  157. policy: {
  158. levels: { '8': { connIdle: 300, downlinkOnly: 1, handshake: 4, uplinkOnly: 1 } },
  159. system: { statsOutboundUplink: true, statsOutboundDownlink: true },
  160. },
  161. routing: {
  162. domainStrategy: 'AsIs',
  163. rules: [{ type: 'field', network: 'tcp,udp', outboundTag: 'proxy' }],
  164. },
  165. stats: {},
  166. };
  167. }
  168. function skeletonOutbounds(): Record<string, unknown>[] {
  169. return [
  170. {
  171. tag: 'direct',
  172. protocol: 'freedom',
  173. settings: { domainStrategy: 'AsIs', redirect: '', noises: [] },
  174. },
  175. { tag: 'block', protocol: 'blackhole', settings: { response: { type: 'http' } } },
  176. ];
  177. }
  178. function proxyOutbound(c: SubClient): Record<string, unknown> {
  179. const streamSettings = buildStreamSettings({
  180. network: c.network ?? 'tcp',
  181. security: c.security ?? 'none',
  182. sni: c.sni,
  183. fingerprint: c.fingerprint,
  184. path: c.path,
  185. host: c.host,
  186. serviceName: c.serviceName,
  187. publicKey: c.publicKey,
  188. shortId: c.shortId,
  189. });
  190. let settings: Record<string, unknown>;
  191. switch (c.protocol) {
  192. case 'vless': {
  193. const s: Record<string, unknown> = {
  194. address: c.address,
  195. port: c.port,
  196. id: c.id ?? '',
  197. encryption: c.encryption || 'none',
  198. level: 8,
  199. };
  200. if (c.flow) s.flow = c.flow;
  201. settings = s;
  202. break;
  203. }
  204. case 'vmess':
  205. settings = {
  206. address: c.address,
  207. port: c.port,
  208. id: c.id ?? '',
  209. security: c.vmessSecurity || 'auto',
  210. level: 8,
  211. };
  212. break;
  213. case 'trojan':
  214. settings = {
  215. servers: [{ address: c.address, port: c.port, password: c.password ?? '', level: 8 }],
  216. };
  217. break;
  218. case 'ss':
  219. settings = {
  220. servers: [
  221. {
  222. address: c.address,
  223. port: c.port,
  224. password: c.password ?? '',
  225. level: 8,
  226. method: c.method || '',
  227. },
  228. ],
  229. };
  230. break;
  231. }
  232. return {
  233. protocol: c.protocol === 'ss' ? 'shadowsocks' : c.protocol,
  234. tag: 'proxy',
  235. streamSettings,
  236. settings,
  237. };
  238. }
  239. // Mirrors the one-document-per-client model only; the panel also emits
  240. // balancer documents (sub_balancers) that are intentionally out of scope here.
  241. function jsonConfig(c: SubClient): Record<string, unknown> {
  242. return {
  243. remarks: c.remark,
  244. ...subJsonSkeleton(),
  245. outbounds: [proxyOutbound(c), ...skeletonOutbounds()],
  246. };
  247. }
  248. export function buildJsonSubscription(clients: SubClient[]): string {
  249. if (clients.length === 0) return '';
  250. const configs = clients.map(jsonConfig);
  251. // 3x-ui returns a single object for one client, an array for several.
  252. return JSON.stringify(configs.length === 1 ? configs[0] : configs, null, 2);
  253. }