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)); + }, + }, + ]} + > +