| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- // Shapes the streamSettings subtree that 3x-ui persists to match what
- // xray-core actually consumes. The panel's Zod defaults mirror the full
- // SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
- // (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
- // them anyway bloats configs and — for sockopt — can inject doc-example
- // values like tcpWindowClamp: 600 that throttle throughput.
- export type StreamWireSide = 'inbound' | 'outbound';
- const PACKET_UP_FIELDS = [
- 'scMaxEachPostBytes',
- 'scMinPostsIntervalMs',
- 'scMaxBufferedPosts',
- ] as const;
- const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
- const PLACEMENT_STRING_FIELDS = [
- 'sessionIDPlacement',
- 'sessionIDKey',
- 'sessionIDTable',
- 'sessionIDLength',
- 'seqPlacement',
- 'seqKey',
- 'uplinkDataPlacement',
- 'uplinkDataKey',
- 'uplinkHTTPMethod',
- 'xPaddingKey',
- 'xPaddingHeader',
- 'xPaddingPlacement',
- 'xPaddingMethod',
- ] as const;
- function isRecord(v: unknown): v is Record<string, unknown> {
- return v != null && typeof v === 'object' && !Array.isArray(v);
- }
- function nonEmptyString(v: unknown): v is string {
- return typeof v === 'string' && v.trim() !== '';
- }
- function hasMeaningfulHeaders(headers: unknown): boolean {
- return isRecord(headers) && Object.keys(headers).length > 0;
- }
- // Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
- // 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
- // mutual-exclusivity check on the `.To` (upper) side.
- export function int32RangeUpper(v: unknown): number {
- if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
- if (typeof v !== 'string') return 0;
- const trimmed = v.trim();
- if (trimmed === '') return 0;
- const parts = trimmed.split('-');
- const n = Number(parts[parts.length - 1]);
- return Number.isFinite(n) ? n : 0;
- }
- // xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
- // and maxConcurrency. A positive maxConnections is an explicit opt-in to
- // connection-pool mode — honor it and drop the leftover maxConcurrency
- // default that load-time hydration backfills onto older saved configs.
- function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
- if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
- const out = { ...xmux };
- delete out.maxConcurrency;
- return out;
- }
- return xmux;
- }
- /** Validates REALITY inbound `target` / `dest` (must include a port). */
- export function validateRealityTarget(target: string): string | undefined {
- const trimmed = target.trim();
- if (!trimmed) {
- return 'pages.inbounds.form.realityTargetRequired';
- }
- // Unix socket destinations (rare, but valid in xray-core).
- if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
- return undefined;
- }
- // Pure port → localhost:port in xray-core.
- if (/^\d+$/.test(trimmed)) {
- const port = Number(trimmed);
- if (port >= 1 && port <= 65535) return undefined;
- return 'pages.inbounds.form.realityTargetInvalidPort';
- }
- const lastColon = trimmed.lastIndexOf(':');
- if (lastColon <= 0 || lastColon === trimmed.length - 1) {
- return 'pages.inbounds.form.realityTargetNeedsPort';
- }
- const portPart = trimmed.slice(lastColon + 1);
- if (!/^\d+$/.test(portPart)) {
- return 'pages.inbounds.form.realityTargetInvalidPort';
- }
- const port = Number(portPart);
- if (port < 1 || port > 65535) {
- return 'pages.inbounds.form.realityTargetInvalidPort';
- }
- return undefined;
- }
- /**
- * Parses a REALITY client-version string the way xray-core's config loader
- * does: one to three dot-separated numeric parts, each 0-255. Returns the
- * parts padded to three entries, or undefined when the string is not a valid
- * version.
- */
- export function parseRealityClientVer(value: string): [number, number, number] | undefined {
- const trimmed = value.trim();
- if (!trimmed) return undefined;
- const parts = trimmed.split('.');
- if (parts.length > 3) return undefined;
- const nums: number[] = [];
- for (const part of parts) {
- if (!/^\d+$/.test(part)) return undefined;
- const n = Number(part);
- if (n > 255) return undefined;
- nums.push(n);
- }
- while (nums.length < 3) nums.push(0);
- return nums as [number, number, number];
- }
- /**
- * Validates a REALITY client-version field; empty means "not set" and is
- * valid. The value is saved exactly as typed and xray-core's part parser
- * accepts no surrounding whitespace, so a value that differs from its
- * trimmed form is rejected rather than silently passed to the wire.
- */
- export function validateRealityClientVer(value: string): string | undefined {
- if (!value) return undefined;
- if (value !== value.trim() || !parseRealityClientVer(value)) {
- return 'pages.inbounds.form.clientVerInvalid';
- }
- return undefined;
- }
- /**
- * Validates the max client-version field: format first, then that a non-empty
- * max is not below a non-empty min (an inverted range rejects every client).
- * An empty or malformed min is left to the min field's own validation.
- */
- export function validateRealityMaxClientVer(max: string, min: string): string | undefined {
- const formatError = validateRealityClientVer(max);
- if (formatError) return formatError;
- const maxParts = parseRealityClientVer(max);
- const minParts = parseRealityClientVer(min);
- if (!maxParts || !minParts) return undefined;
- for (let i = 0; i < 3; i++) {
- if (maxParts[i] !== minParts[i]) {
- return maxParts[i] < minParts[i] ? 'pages.inbounds.form.maxClientVerBelowMin' : undefined;
- }
- }
- return undefined;
- }
- function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
- const lift = (legacy: string, renamed: string) => {
- const v = obj[legacy];
- if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
- obj[renamed] = v;
- }
- delete obj[legacy];
- };
- lift('sessionPlacement', 'sessionIDPlacement');
- lift('sessionKey', 'sessionIDKey');
- }
- function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
- for (const key of keys) {
- const v = obj[key];
- if (v === '' || v == null) delete obj[key];
- }
- }
- function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
- for (const key of keys) {
- if (obj[key] === false) delete obj[key];
- }
- }
- function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
- for (const key of keys) {
- if (obj[key] === 0) delete obj[key];
- }
- }
- function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
- const out: Record<string, unknown> = { ...raw };
- if (out.fingerprint === '') delete out.fingerprint;
- // Empty server-side tuning fields mean "use xray-core's default" — never emit them.
- if (Array.isArray(out.curvePreferences) && out.curvePreferences.length === 0) {
- delete out.curvePreferences;
- }
- if (out.masterKeyLog === '' || out.masterKeyLog == null) delete out.masterKeyLog;
- if (isRecord(out.echSockopt)) {
- const echSock = normalizeSockoptForWire(out.echSockopt);
- if (echSock) {
- out.echSockopt = echSock;
- } else {
- delete out.echSockopt;
- }
- }
- const settings = out.settings;
- if (isRecord(settings)) {
- const settingsOut: Record<string, unknown> = { ...settings };
- if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
- out.settings = settingsOut;
- }
- return out;
- }
- export function normalizeXhttpForWire(
- raw: Record<string, unknown>,
- side: StreamWireSide,
- ): Record<string, unknown> {
- const out: Record<string, unknown> = { ...raw };
- liftLegacyXhttpSessionKeys(out);
- const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
- const enableXmux = out.enableXmux === true;
- delete out.enableXmux;
- if (side === 'inbound') {
- if (!enableXmux) delete out.xmux;
- // scMinPostsIntervalMs is a client-only tuning knob that subscriptions
- // must propagate to clients. Only strip the xray-core default ("30")
- // or empty values — the literal "30" is a known DPI fingerprint (#5141).
- if (out.scMinPostsIntervalMs === '' || out.scMinPostsIntervalMs === '30') {
- delete out.scMinPostsIntervalMs;
- }
- delete out.uplinkChunkSize;
- }
- if (isRecord(out.xmux)) {
- out.xmux = resolveXmuxExclusivity(out.xmux);
- }
- dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
- // Empty tuning fields mean "use xray-core's default" — never emit them.
- dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);
- if (!hasMeaningfulHeaders(out.headers)) {
- delete out.headers;
- }
- if (out.xPaddingObfsMode !== true) {
- delete out.xPaddingObfsMode;
- dropEmptyStrings(out, ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod']);
- }
- if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
- if (out.noSSEHeader !== true) delete out.noSSEHeader;
- if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
- if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
- if (mode === 'stream-one') {
- for (const key of PACKET_UP_FIELDS) delete out[key];
- for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
- } else if (mode === 'stream-up') {
- for (const key of PACKET_UP_FIELDS) delete out[key];
- if (side === 'outbound') {
- delete out.scStreamUpServerSecs;
- }
- } else if (mode === 'packet-up') {
- delete out.scStreamUpServerSecs;
- }
- return out;
- }
- export function normalizeSockoptForWire(
- raw: Record<string, unknown>,
- ): Record<string, unknown> | undefined {
- const out: Record<string, unknown> = { ...raw };
- dropZeroNumbers(out, [
- 'tcpWindowClamp',
- 'tcpMaxSeg',
- 'tcpUserTimeout',
- 'tcpKeepAliveIdle',
- 'tcpKeepAliveInterval',
- 'mark',
- ]);
- dropFalseFlags(out, ['acceptProxyProtocol', 'tcpFastOpen', 'tcpMptcp', 'penetrate', 'V6Only']);
- if (out.tproxy === 'off') delete out.tproxy;
- if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
- if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
- if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
- if (nonEmptyString(out.interface) === false) delete out.interface;
- if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
- delete out.trustedXForwardedFor;
- }
- if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
- delete out.customSockopt;
- }
- const he = out.happyEyeballs;
- if (isRecord(he)) {
- const heOut: Record<string, unknown> = { ...he };
- if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
- if (heOut.interleave === 1) delete heOut.interleave;
- if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
- if (Object.keys(heOut).length === 0) {
- delete out.happyEyeballs;
- } else {
- out.happyEyeballs = heOut;
- }
- }
- if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
- if (Object.keys(out).length === 0) return undefined;
- return out;
- }
- // Emit `finalmask` only when a mask exists (legacy `hasFinalMask`), and drop an empty
- // sub-array beside a populated one so the payload carries no stray `"tcp": []`.
- export function dropEmptyFinalMask(stream: Record<string, unknown>): void {
- const fm = stream.finalmask as { tcp?: unknown[]; udp?: unknown[]; quicParams?: unknown };
- if (!fm || typeof fm !== 'object') return;
- const hasTcp = Array.isArray(fm.tcp) && fm.tcp.length > 0;
- const hasUdp = Array.isArray(fm.udp) && fm.udp.length > 0;
- if (!hasTcp && !hasUdp && fm.quicParams == null) {
- delete stream.finalmask;
- return;
- }
- if (!hasTcp) delete fm.tcp;
- if (!hasUdp) delete fm.udp;
- }
- export function normalizeStreamSettingsForWire(
- stream: Record<string, unknown>,
- opts: { side: StreamWireSide },
- ): Record<string, unknown> {
- const out: Record<string, unknown> = { ...stream };
- const xhttp = out.xhttpSettings;
- if (isRecord(xhttp)) {
- out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
- }
- const tls = out.tlsSettings;
- if (isRecord(tls)) {
- out.tlsSettings = normalizeTlsForWire(tls);
- }
- const sockopt = out.sockopt;
- if (isRecord(sockopt)) {
- const normalized = normalizeSockoptForWire(sockopt);
- if (normalized) {
- out.sockopt = normalized;
- } else {
- delete out.sockopt;
- }
- }
- return out;
- }
|