stream-wire-normalize.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  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. export 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. A positive maxConnections is an explicit opt-in to
  52. // connection-pool mode — honor it and drop the leftover maxConcurrency
  53. // default that load-time hydration backfills onto older saved configs.
  54. function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
  55. if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
  56. const out = { ...xmux };
  57. delete out.maxConcurrency;
  58. return out;
  59. }
  60. return xmux;
  61. }
  62. /** Validates REALITY inbound `target` / `dest` (must include a port). */
  63. export function validateRealityTarget(target: string): string | undefined {
  64. const trimmed = target.trim();
  65. if (!trimmed) {
  66. return 'pages.inbounds.form.realityTargetRequired';
  67. }
  68. // Unix socket destinations (rare, but valid in xray-core).
  69. if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
  70. return undefined;
  71. }
  72. // Pure port → localhost:port in xray-core.
  73. if (/^\d+$/.test(trimmed)) {
  74. const port = Number(trimmed);
  75. if (port >= 1 && port <= 65535) return undefined;
  76. return 'pages.inbounds.form.realityTargetInvalidPort';
  77. }
  78. const lastColon = trimmed.lastIndexOf(':');
  79. if (lastColon <= 0 || lastColon === trimmed.length - 1) {
  80. return 'pages.inbounds.form.realityTargetNeedsPort';
  81. }
  82. const portPart = trimmed.slice(lastColon + 1);
  83. if (!/^\d+$/.test(portPart)) {
  84. return 'pages.inbounds.form.realityTargetInvalidPort';
  85. }
  86. const port = Number(portPart);
  87. if (port < 1 || port > 65535) {
  88. return 'pages.inbounds.form.realityTargetInvalidPort';
  89. }
  90. return undefined;
  91. }
  92. /**
  93. * Parses a REALITY client-version string the way xray-core's config loader
  94. * does: one to three dot-separated numeric parts, each 0-255. Returns the
  95. * parts padded to three entries, or undefined when the string is not a valid
  96. * version.
  97. */
  98. export function parseRealityClientVer(value: string): [number, number, number] | undefined {
  99. const trimmed = value.trim();
  100. if (!trimmed) return undefined;
  101. const parts = trimmed.split('.');
  102. if (parts.length > 3) return undefined;
  103. const nums: number[] = [];
  104. for (const part of parts) {
  105. if (!/^\d+$/.test(part)) return undefined;
  106. const n = Number(part);
  107. if (n > 255) return undefined;
  108. nums.push(n);
  109. }
  110. while (nums.length < 3) nums.push(0);
  111. return nums as [number, number, number];
  112. }
  113. /**
  114. * Validates a REALITY client-version field; empty means "not set" and is
  115. * valid. The value is saved exactly as typed and xray-core's part parser
  116. * accepts no surrounding whitespace, so a value that differs from its
  117. * trimmed form is rejected rather than silently passed to the wire.
  118. */
  119. export function validateRealityClientVer(value: string): string | undefined {
  120. if (!value) return undefined;
  121. if (value !== value.trim() || !parseRealityClientVer(value)) {
  122. return 'pages.inbounds.form.clientVerInvalid';
  123. }
  124. return undefined;
  125. }
  126. /**
  127. * Validates the max client-version field: format first, then that a non-empty
  128. * max is not below a non-empty min (an inverted range rejects every client).
  129. * An empty or malformed min is left to the min field's own validation.
  130. */
  131. export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
  132. const formatError = validateRealityClientVer(max);
  133. if (formatError) return formatError;
  134. const maxParts = parseRealityClientVer(max);
  135. const minParts = parseRealityClientVer(min);
  136. if (!maxParts || !minParts) return undefined;
  137. for (let i = 0; i < 3; i++) {
  138. if (maxParts[i] !== minParts[i]) {
  139. return maxParts[i] < minParts[i] ? 'pages.inbounds.form.maxClientVerBelowMin' : undefined;
  140. }
  141. }
  142. return undefined;
  143. }
  144. function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
  145. const lift = (legacy: string, renamed: string) => {
  146. const v = obj[legacy];
  147. if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
  148. obj[renamed] = v;
  149. }
  150. delete obj[legacy];
  151. };
  152. lift('sessionPlacement', 'sessionIDPlacement');
  153. lift('sessionKey', 'sessionIDKey');
  154. }
  155. function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
  156. for (const key of keys) {
  157. const v = obj[key];
  158. if (v === '' || v == null) delete obj[key];
  159. }
  160. }
  161. function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
  162. for (const key of keys) {
  163. if (obj[key] === false) delete obj[key];
  164. }
  165. }
  166. function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
  167. for (const key of keys) {
  168. if (obj[key] === 0) delete obj[key];
  169. }
  170. }
  171. function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
  172. const out: Record<string, unknown> = { ...raw };
  173. if (out.fingerprint === '') delete out.fingerprint;
  174. // Empty server-side tuning fields mean "use xray-core's default" — never emit them.
  175. if (Array.isArray(out.curvePreferences) && out.curvePreferences.length === 0) {
  176. delete out.curvePreferences;
  177. }
  178. if (out.masterKeyLog === '' || out.masterKeyLog == null) delete out.masterKeyLog;
  179. if (isRecord(out.echSockopt)) {
  180. const echSock = normalizeSockoptForWire(out.echSockopt);
  181. if (echSock) {
  182. out.echSockopt = echSock;
  183. } else {
  184. delete out.echSockopt;
  185. }
  186. }
  187. const settings = out.settings;
  188. if (isRecord(settings)) {
  189. const settingsOut: Record<string, unknown> = { ...settings };
  190. if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
  191. out.settings = settingsOut;
  192. }
  193. return out;
  194. }
  195. export function normalizeXhttpForWire(
  196. raw: Record<string, unknown>,
  197. side: StreamWireSide,
  198. ): Record<string, unknown> {
  199. const out: Record<string, unknown> = { ...raw };
  200. liftLegacyXhttpSessionKeys(out);
  201. const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
  202. const enableXmux = out.enableXmux === true;
  203. delete out.enableXmux;
  204. if (side === 'inbound') {
  205. if (!enableXmux) delete out.xmux;
  206. // scMinPostsIntervalMs is a client-only tuning knob that subscriptions
  207. // must propagate to clients. Only strip the xray-core default ("30")
  208. // or empty values — the literal "30" is a known DPI fingerprint (#5141).
  209. if (out.scMinPostsIntervalMs === '' || out.scMinPostsIntervalMs === '30') {
  210. delete out.scMinPostsIntervalMs;
  211. }
  212. delete out.uplinkChunkSize;
  213. }
  214. if (isRecord(out.xmux)) {
  215. out.xmux = resolveXmuxExclusivity(out.xmux);
  216. }
  217. dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
  218. // Empty tuning fields mean "use xray-core's default" — never emit them.
  219. dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);
  220. if (!hasMeaningfulHeaders(out.headers)) {
  221. delete out.headers;
  222. }
  223. if (out.xPaddingObfsMode !== true) {
  224. delete out.xPaddingObfsMode;
  225. dropEmptyStrings(out, ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod']);
  226. }
  227. if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
  228. if (out.noSSEHeader !== true) delete out.noSSEHeader;
  229. if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
  230. if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
  231. if (mode === 'stream-one') {
  232. for (const key of PACKET_UP_FIELDS) delete out[key];
  233. for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
  234. } else if (mode === 'stream-up') {
  235. for (const key of PACKET_UP_FIELDS) delete out[key];
  236. if (side === 'outbound') {
  237. delete out.scStreamUpServerSecs;
  238. }
  239. } else if (mode === 'packet-up') {
  240. delete out.scStreamUpServerSecs;
  241. }
  242. return out;
  243. }
  244. export function normalizeSockoptForWire(
  245. raw: Record<string, unknown>,
  246. ): Record<string, unknown> | undefined {
  247. const out: Record<string, unknown> = { ...raw };
  248. dropZeroNumbers(out, [
  249. 'tcpWindowClamp',
  250. 'tcpMaxSeg',
  251. 'tcpUserTimeout',
  252. 'tcpKeepAliveIdle',
  253. 'tcpKeepAliveInterval',
  254. 'mark',
  255. ]);
  256. dropFalseFlags(out, ['acceptProxyProtocol', 'tcpFastOpen', 'tcpMptcp', 'penetrate', 'V6Only']);
  257. if (out.tproxy === 'off') delete out.tproxy;
  258. if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
  259. if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
  260. if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
  261. if (nonEmptyString(out.interface) === false) delete out.interface;
  262. if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
  263. delete out.trustedXForwardedFor;
  264. }
  265. if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
  266. delete out.customSockopt;
  267. }
  268. const he = out.happyEyeballs;
  269. if (isRecord(he)) {
  270. const heOut: Record<string, unknown> = { ...he };
  271. if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
  272. if (heOut.interleave === 1) delete heOut.interleave;
  273. if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
  274. if (Object.keys(heOut).length === 0) {
  275. delete out.happyEyeballs;
  276. } else {
  277. out.happyEyeballs = heOut;
  278. }
  279. }
  280. if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
  281. if (Object.keys(out).length === 0) return undefined;
  282. return out;
  283. }
  284. // Emit `finalmask` only when a mask exists (legacy `hasFinalMask`), and drop an empty
  285. // sub-array beside a populated one so the payload carries no stray `"tcp": []`.
  286. export function dropEmptyFinalMask(stream: Record<string, unknown>): void {
  287. const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown };
  288. if (!fm || typeof fm !== 'object') return;
  289. const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
  290. const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
  291. if (!hasTcp && !hasUdp && fm.quicParams == null) {
  292. delete stream.finalmask;
  293. return;
  294. }
  295. if (!hasTcp) delete fm.tcp;
  296. if (!hasUdp) delete fm.udp;
  297. }
  298. export function normalizeStreamSettingsForWire(
  299. stream: Record<string, unknown>,
  300. opts: { side: StreamWireSide },
  301. ): Record<string, unknown> {
  302. const out: Record<string, unknown> = { ...stream };
  303. const xhttp = out.xhttpSettings;
  304. if (isRecord(xhttp)) {
  305. out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
  306. }
  307. const tls = out.tlsSettings;
  308. if (isRecord(tls)) {
  309. out.tlsSettings = normalizeTlsForWire(tls);
  310. }
  311. const sockopt = out.sockopt;
  312. if (isRecord(sockopt)) {
  313. const normalized = normalizeSockoptForWire(sockopt);
  314. if (normalized) {
  315. out.sockopt = normalized;
  316. } else {
  317. delete out.sockopt;
  318. }
  319. }
  320. return out;
  321. }