stream-wire-normalize.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. // Shapes the streamSettings subtree that 3x-ui persists to match what
  2. // xray-core actually consumes. The panel's Zod defaults mirror the full
  3. // SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
  4. // (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
  5. // them anyway bloats configs and — for sockopt — can inject doc-example
  6. // values like tcpWindowClamp: 600 that throttle throughput.
  7. export type StreamWireSide = 'inbound' | 'outbound';
  8. const PACKET_UP_FIELDS = [
  9. 'scMaxEachPostBytes',
  10. 'scMinPostsIntervalMs',
  11. 'scMaxBufferedPosts',
  12. ] as const;
  13. const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
  14. const PLACEMENT_STRING_FIELDS = [
  15. 'sessionIDPlacement',
  16. 'sessionIDKey',
  17. 'sessionIDTable',
  18. 'sessionIDLength',
  19. 'seqPlacement',
  20. 'seqKey',
  21. 'uplinkDataPlacement',
  22. 'uplinkDataKey',
  23. 'uplinkHTTPMethod',
  24. 'xPaddingKey',
  25. 'xPaddingHeader',
  26. 'xPaddingPlacement',
  27. 'xPaddingMethod',
  28. ] as const;
  29. function isRecord(v: unknown): v is Record<string, unknown> {
  30. return v != null && typeof v === 'object' && !Array.isArray(v);
  31. }
  32. function nonEmptyString(v: unknown): v is string {
  33. return typeof v === 'string' && v.trim() !== '';
  34. }
  35. function hasMeaningfulHeaders(headers: unknown): boolean {
  36. return isRecord(headers) && Object.keys(headers).length > 0;
  37. }
  38. // Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
  39. // 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
  40. // mutual-exclusivity check on the `.To` (upper) side.
  41. function int32RangeUpper(v: unknown): number {
  42. if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
  43. if (typeof v !== 'string') return 0;
  44. const trimmed = v.trim();
  45. if (trimmed === '') return 0;
  46. const parts = trimmed.split('-');
  47. const n = Number(parts[parts.length - 1]);
  48. return Number.isFinite(n) ? n : 0;
  49. }
  50. // xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
  51. // and maxConcurrency ("maxConnections cannot be specified together with
  52. // maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
  53. // XMUX is enabled, so any explicit maxConnections would otherwise always
  54. // collide and make xray refuse the config. maxConnections defaults to 0
  55. // (off), so a positive value is an explicit opt-in to connection-pool
  56. // mode — honor it and drop the leftover default maxConcurrency, matching
  57. // core's "one strategy at a time" semantics.
  58. function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
  59. if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
  60. const out = { ...xmux };
  61. delete out.maxConcurrency;
  62. return out;
  63. }
  64. return xmux;
  65. }
  66. /** Validates REALITY inbound `target` / `dest` (must include a port). */
  67. export function validateRealityTarget(target: string): string | undefined {
  68. const trimmed = target.trim();
  69. if (!trimmed) {
  70. return 'pages.inbounds.form.realityTargetRequired';
  71. }
  72. // Unix socket destinations (rare, but valid in xray-core).
  73. if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
  74. return undefined;
  75. }
  76. // Pure port → localhost:port in xray-core.
  77. if (/^\d+$/.test(trimmed)) {
  78. const port = Number(trimmed);
  79. if (port >= 1 && port <= 65535) return undefined;
  80. return 'pages.inbounds.form.realityTargetInvalidPort';
  81. }
  82. const lastColon = trimmed.lastIndexOf(':');
  83. if (lastColon <= 0 || lastColon === trimmed.length - 1) {
  84. return 'pages.inbounds.form.realityTargetNeedsPort';
  85. }
  86. const portPart = trimmed.slice(lastColon + 1);
  87. if (!/^\d+$/.test(portPart)) {
  88. return 'pages.inbounds.form.realityTargetInvalidPort';
  89. }
  90. const port = Number(portPart);
  91. if (port < 1 || port > 65535) {
  92. return 'pages.inbounds.form.realityTargetInvalidPort';
  93. }
  94. return undefined;
  95. }
  96. function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
  97. const lift = (legacy: string, renamed: string) => {
  98. const v = obj[legacy];
  99. if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
  100. obj[renamed] = v;
  101. }
  102. delete obj[legacy];
  103. };
  104. lift('sessionPlacement', 'sessionIDPlacement');
  105. lift('sessionKey', 'sessionIDKey');
  106. }
  107. function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
  108. for (const key of keys) {
  109. const v = obj[key];
  110. if (v === '' || v == null) delete obj[key];
  111. }
  112. }
  113. function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
  114. for (const key of keys) {
  115. if (obj[key] === false) delete obj[key];
  116. }
  117. }
  118. function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
  119. for (const key of keys) {
  120. if (obj[key] === 0) delete obj[key];
  121. }
  122. }
  123. function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
  124. const out: Record<string, unknown> = { ...raw };
  125. if (out.fingerprint === '') delete out.fingerprint;
  126. // Empty server-side tuning fields mean "use xray-core's default" — never emit them.
  127. if (Array.isArray(out.curvePreferences) && out.curvePreferences.length === 0) {
  128. delete out.curvePreferences;
  129. }
  130. if (out.masterKeyLog === '' || out.masterKeyLog == null) delete out.masterKeyLog;
  131. if (isRecord(out.echSockopt)) {
  132. const echSock = normalizeSockoptForWire(out.echSockopt);
  133. if (echSock) {
  134. out.echSockopt = echSock;
  135. } else {
  136. delete out.echSockopt;
  137. }
  138. }
  139. const settings = out.settings;
  140. if (isRecord(settings)) {
  141. const settingsOut: Record<string, unknown> = { ...settings };
  142. if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
  143. out.settings = settingsOut;
  144. }
  145. return out;
  146. }
  147. export function normalizeXhttpForWire(
  148. raw: Record<string, unknown>,
  149. side: StreamWireSide,
  150. ): Record<string, unknown> {
  151. const out: Record<string, unknown> = { ...raw };
  152. liftLegacyXhttpSessionKeys(out);
  153. const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
  154. const enableXmux = out.enableXmux === true;
  155. delete out.enableXmux;
  156. if (side === 'inbound') {
  157. if (!enableXmux) delete out.xmux;
  158. // scMinPostsIntervalMs is a client-only tuning knob that subscriptions
  159. // must propagate to clients. Only strip the xray-core default ("30")
  160. // or empty values — the literal "30" is a known DPI fingerprint (#5141).
  161. if (out.scMinPostsIntervalMs === '' || out.scMinPostsIntervalMs === '30') {
  162. delete out.scMinPostsIntervalMs;
  163. }
  164. delete out.uplinkChunkSize;
  165. }
  166. if (isRecord(out.xmux)) {
  167. out.xmux = resolveXmuxExclusivity(out.xmux);
  168. }
  169. dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
  170. // Empty tuning fields mean "use xray-core's default" — never emit them.
  171. dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);
  172. if (!hasMeaningfulHeaders(out.headers)) {
  173. delete out.headers;
  174. }
  175. if (out.xPaddingObfsMode !== true) {
  176. delete out.xPaddingObfsMode;
  177. dropEmptyStrings(out, [
  178. 'xPaddingKey',
  179. 'xPaddingHeader',
  180. 'xPaddingPlacement',
  181. 'xPaddingMethod',
  182. ]);
  183. }
  184. if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
  185. if (out.noSSEHeader !== true) delete out.noSSEHeader;
  186. if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
  187. if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
  188. if (mode === 'stream-one') {
  189. for (const key of PACKET_UP_FIELDS) delete out[key];
  190. for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
  191. } else if (mode === 'stream-up') {
  192. for (const key of PACKET_UP_FIELDS) delete out[key];
  193. if (side === 'outbound') {
  194. delete out.scStreamUpServerSecs;
  195. }
  196. } else if (mode === 'packet-up') {
  197. delete out.scStreamUpServerSecs;
  198. }
  199. return out;
  200. }
  201. export function normalizeSockoptForWire(
  202. raw: Record<string, unknown>,
  203. ): Record<string, unknown> | undefined {
  204. const out: Record<string, unknown> = { ...raw };
  205. dropZeroNumbers(out, [
  206. 'tcpWindowClamp',
  207. 'tcpMaxSeg',
  208. 'tcpUserTimeout',
  209. 'tcpKeepAliveIdle',
  210. 'tcpKeepAliveInterval',
  211. 'mark',
  212. ]);
  213. dropFalseFlags(out, [
  214. 'acceptProxyProtocol',
  215. 'tcpFastOpen',
  216. 'tcpMptcp',
  217. 'penetrate',
  218. 'V6Only',
  219. ]);
  220. if (out.tproxy === 'off') delete out.tproxy;
  221. if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
  222. if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
  223. if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
  224. if (nonEmptyString(out.interface) === false) delete out.interface;
  225. if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
  226. delete out.trustedXForwardedFor;
  227. }
  228. if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
  229. delete out.customSockopt;
  230. }
  231. const he = out.happyEyeballs;
  232. if (isRecord(he)) {
  233. const heOut: Record<string, unknown> = { ...he };
  234. if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
  235. if (heOut.interleave === 1) delete heOut.interleave;
  236. if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
  237. if (Object.keys(heOut).length === 0) {
  238. delete out.happyEyeballs;
  239. } else {
  240. out.happyEyeballs = heOut;
  241. }
  242. }
  243. if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
  244. if (Object.keys(out).length === 0) return undefined;
  245. return out;
  246. }
  247. export function normalizeStreamSettingsForWire(
  248. stream: Record<string, unknown>,
  249. opts: { side: StreamWireSide },
  250. ): Record<string, unknown> {
  251. const out: Record<string, unknown> = { ...stream };
  252. const xhttp = out.xhttpSettings;
  253. if (isRecord(xhttp)) {
  254. out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
  255. }
  256. const tls = out.tlsSettings;
  257. if (isRecord(tls)) {
  258. out.tlsSettings = normalizeTlsForWire(tls);
  259. }
  260. const sockopt = out.sockopt;
  261. if (isRecord(sockopt)) {
  262. const normalized = normalizeSockoptForWire(sockopt);
  263. if (normalized) {
  264. out.sockopt = normalized;
  265. } else {
  266. delete out.sockopt;
  267. }
  268. }
  269. return out;
  270. }