From 6ed6f57b5ce0548c4bb857fd68055ed6f3ceddcf Mon Sep 17 00:00:00 2001 From: nima1024m <114405577+nima1024m@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:10:32 +0330 Subject: [PATCH] fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988) * fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults to 0, split server/client Reality fields on save, validate target host:port in the inbound form, and expose Happy Eyeballs for the direct freedom outbound. Co-authored-by: Cursor * fix(panel): keep REALITY public key on the wire, guard freedom noises The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload. Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler. Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved. --------- Co-authored-by: Cursor Co-authored-by: MHSanaei --- frontend/src/lib/xray/inbound-form-adapter.ts | 8 +- .../src/lib/xray/outbound-form-adapter.ts | 7 +- .../src/lib/xray/stream-wire-normalize.ts | 225 ++++++++++++++++ .../pages/inbounds/form/security/reality.tsx | 21 +- .../pages/inbounds/form/transport/sockopt.tsx | 1 + frontend/src/pages/xray/basics/BasicsTab.tsx | 91 +++++++ .../xray/outbounds/transport/sockopt.tsx | 1 + .../src/schemas/protocols/stream/sockopt.ts | 14 +- .../test/__snapshots__/sockopt.test.ts.snap | 20 +- .../src/test/stream-wire-normalize.test.ts | 243 ++++++++++++++++++ web/translation/en-US.json | 8 + 11 files changed, 616 insertions(+), 23 deletions(-) create mode 100644 frontend/src/lib/xray/stream-wire-normalize.ts create mode 100644 frontend/src/test/stream-wire-normalize.test.ts diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index 3488111f..07b3c75a 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -10,6 +10,7 @@ import { import type { StreamSettings } from '@/schemas/api/inbound'; import type { Sniffing } from '@/schemas/primitives'; import type { z } from 'zod'; +import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; // Plain-data adapter between the panel's stored inbound row shape and // the typed InboundFormValues that Form.useForm carries inside @@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP if (Array.isArray(settingsPruned.clients)) { settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients); } - const streamPruned = values.streamSettings + let streamPruned = values.streamSettings ? ((pruneEmpty(values.streamSettings) ?? {}) as Record) : undefined; - if (streamPruned) stripTlsCertUseFile(streamPruned); + if (streamPruned) { + streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' }); + stripTlsCertUseFile(streamPruned); + } dropLegacyOptionalEmpties(settingsPruned, streamPruned); const payload: WireInboundPayload = { up: values.up, diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index cfee7284..4caf2d52 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -1,4 +1,5 @@ import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; +import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { Wireguard } from '@/utils'; import type { @@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) { userLevel: s.userLevel || undefined, proxyProtocol: s.proxyProtocol || undefined, fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined, - noises: s.noises.length > 0 ? s.noises : undefined, - finalRules: s.finalRules.length > 0 + noises: s.noises && s.noises.length > 0 ? s.noises : undefined, + finalRules: s.finalRules && s.finalRules.length > 0 ? s.finalRules.map((r) => ({ action: r.action, network: r.network || undefined, @@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw { if (!xmuxEnabled) delete cleaned.xmux; next.xhttpSettings = dropEmptyStrings(cleaned); } - return next; + return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw; } function muxAllowed(values: OutboundFormValues): boolean { diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts new file mode 100644 index 00000000..f18b47af --- /dev/null +++ b/frontend/src/lib/xray/stream-wire-normalize.ts @@ -0,0 +1,225 @@ +// 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 = [ + 'sessionPlacement', + 'sessionKey', + 'seqPlacement', + 'seqKey', + 'uplinkDataPlacement', + 'uplinkDataKey', + 'uplinkHTTPMethod', + 'xPaddingKey', + 'xPaddingHeader', + 'xPaddingPlacement', + 'xPaddingMethod', +] as const; + +function isRecord(v: unknown): v is Record { + 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; +} + +/** 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; +} + +function dropEmptyStrings(obj: Record, keys: readonly string[]): void { + for (const key of keys) { + const v = obj[key]; + if (v === '' || v == null) delete obj[key]; + } +} + +function dropFalseFlags(obj: Record, keys: readonly string[]): void { + for (const key of keys) { + if (obj[key] === false) delete obj[key]; + } +} + +function dropZeroNumbers(obj: Record, keys: readonly string[]): void { + for (const key of keys) { + if (obj[key] === 0) delete obj[key]; + } +} + +export function normalizeXhttpForWire( + raw: Record, + side: StreamWireSide, +): Record { + const out: Record = { ...raw }; + const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto'; + + delete out.enableXmux; + + if (side === 'inbound') { + delete out.xmux; + delete out.scMinPostsIntervalMs; + delete out.uplinkChunkSize; + } + + dropEmptyStrings(out, PLACEMENT_STRING_FIELDS); + + 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, +): Record | undefined { + const out: Record = { ...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 = { ...he }; + if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs; + 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; +} + +export function normalizeStreamSettingsForWire( + stream: Record, + opts: { side: StreamWireSide }, +): Record { + const out: Record = { ...stream }; + + const xhttp = out.xhttpSettings; + if (isRecord(xhttp)) { + out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side); + } + + const sockopt = out.sockopt; + if (isRecord(sockopt)) { + const normalized = normalizeSockoptForWire(sockopt); + if (normalized) { + out.sockopt = normalized; + } else { + delete out.sockopt; + } + } + + return out; +} diff --git a/frontend/src/pages/inbounds/form/security/reality.tsx b/frontend/src/pages/inbounds/form/security/reality.tsx index fa33cdf3..fd500622 100644 --- a/frontend/src/pages/inbounds/form/security/reality.tsx +++ b/frontend/src/pages/inbounds/form/security/reality.tsx @@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { ReloadOutlined } from '@ant-design/icons'; import { UTLS_FINGERPRINT } from '@/schemas/primitives'; +import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize'; interface RealityFormProps { saving: boolean; @@ -44,10 +45,24 @@ export default function RealityForm({ options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} /> - + - - + { + const errKey = validateRealityTarget(typeof value === 'string' ? value : ''); + if (errKey) throw new Error(t(errKey)); + }, + }, + ]} + > +