diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66347ba7..33634911 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-i18next": "^17.0.8", - "react-router-dom": "^7.15.1", + "react-router-dom": "^7.16.0", "recharts": "^3.8.1", "swagger-ui-react": "^5.32.6", "zod": "^4.4.3" @@ -6004,9 +6004,9 @@ } }, "node_modules/react-router": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", - "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6026,12 +6026,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.15.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", - "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", "license": "MIT", "dependencies": { - "react-router": "7.15.1" + "react-router": "7.16.0" }, "engines": { "node": ">=20.0.0" diff --git a/frontend/package.json b/frontend/package.json index 90477295..66796ef9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-i18next": "^17.0.8", - "react-router-dom": "^7.15.1", + "react-router-dom": "^7.16.0", "recharts": "^3.8.1", "swagger-ui-react": "^5.32.6", "zod": "^4.4.3" diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 5ecc41b6..310532fb 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -1,3 +1,4 @@ +import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; import { Wireguard } from '@/utils'; import type { @@ -345,6 +346,23 @@ export interface RawOutboundRow { mux?: unknown; } +export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); + +function hydrateStreamForm(stream: Raw): OutboundStreamFormValues { + const next = { ...stream }; + const xh = next.xhttpSettings; + if (xh && typeof xh === 'object' && !Array.isArray(xh)) { + const xhttp = { ...(xh as Raw) }; + const xmux = xhttp.xmux; + if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) { + xhttp.enableXmux = true; + xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Raw) }; + } + next.xhttpSettings = xhttp; + } + return next as unknown as OutboundStreamFormValues; +} + export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues { const protocol = asString(raw.protocol, 'vless'); const settings = asObject(raw.settings); @@ -355,7 +373,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues && typeof raw.streamSettings === 'object' && Object.keys(raw.streamSettings as Raw).length > 0; const streamSettings = hasStream - ? (raw.streamSettings as unknown as OutboundStreamFormValues) + ? hydrateStreamForm(raw.streamSettings as Raw) : undefined; let typed: OutboundFormSettings; @@ -558,7 +576,9 @@ function stripUiOnlyStreamFields(stream: unknown): Raw { const xh = next.xhttpSettings; if (xh && typeof xh === 'object') { const cleaned = { ...(xh as Raw) }; + const xmuxEnabled = cleaned.enableXmux === true; delete cleaned.enableXmux; + if (!xmuxEnabled) delete cleaned.xmux; next.xhttpSettings = dropEmptyStrings(cleaned); } return next; diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx index 29f2f2bc..a0ebfb76 100644 --- a/frontend/src/pages/xray/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/OutboundFormModal.tsx @@ -21,6 +21,7 @@ import InputAddon from '@/components/InputAddon'; import JsonEditor from '@/components/JsonEditor'; import { Wireguard } from '@/utils'; import { + XMUX_DEFAULTS, formValuesToWirePayload, rawOutboundToFormValues, } from '@/lib/xray/outbound-form-adapter'; @@ -335,6 +336,14 @@ export default function OutboundFormModal({ form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity }); } + function onXmuxToggle(checked: boolean) { + if (!checked) return; + const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']); + const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0; + if (hasValues) return; + form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS }); + } + const duplicateTag = useMemo(() => { const myTag = tag.trim(); if (!myTag) return false; @@ -392,17 +401,40 @@ export default function OutboundFormModal({ } async function onOk() { - if (activeKey === '2' && !applyJsonToForm()) return; - try { - await form.validateFields(); - } catch { + let values: OutboundFormValues; + if (activeKey === '2') { + const raw = jsonText.trim(); + if (!raw) return; + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch (e) { + messageApi.error(`JSON: ${(e as Error).message}`); + return; + } + values = rawOutboundToFormValues(parsed); + form.resetFields(); + form.setFieldsValue(values); + setJsonDirty(false); + } else { + try { + await form.validateFields(); + } catch { + return; + } + values = form.getFieldsValue(true) as OutboundFormValues; + } + const tagValue = (values.tag ?? '').trim(); + if (!tagValue) { + messageApi.error(t('pages.xray.outboundForm.tagRequired')); return; } - if (duplicateTag) { + const isDuplicateTag = (existingTags || []).includes(tagValue) + && !(isEdit && (outboundProp?.tag as string | undefined) === tagValue); + if (isDuplicateTag) { messageApi.error('Tag already used by another outbound'); return; } - const values = form.getFieldsValue(true) as OutboundFormValues; onConfirm(formValuesToWirePayload(values)); } @@ -1188,47 +1220,6 @@ export default function OutboundFormModal({ > - - typeof v === 'string' - ? v.split(',').map((s) => s.trim()).filter(Boolean) - : Array.isArray(v) ? v : [] - } - getValueProps={(v: unknown) => ({ - value: Array.isArray(v) ? v.join(',') : '', - })} - > - - - - typeof v === 'string' - ? v.split(',').map((s) => s.trim()).filter(Boolean) - : Array.isArray(v) ? v : ['/'] - } - getValueProps={(v: unknown) => ({ - value: Array.isArray(v) ? v.join(',') : '/', - })} - > - - - + {() => { diff --git a/frontend/src/pages/xray/OutboundsTab.tsx b/frontend/src/pages/xray/OutboundsTab.tsx index 9c0e398b..4eb5cd1d 100644 --- a/frontend/src/pages/xray/OutboundsTab.tsx +++ b/frontend/src/pages/xray/OutboundsTab.tsx @@ -375,7 +375,7 @@ export default function OutboundsTab({ }, ], // eslint-disable-next-line react-hooks/exhaustive-deps - [t, testMode, rows.length, outboundTestStates, outboundsTraffic], + [t, testMode, rows, outboundTestStates, outboundsTraffic], ); return ( diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index f4b3b0e0..e674d513 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/frontend/src/test/outbound-form-adapter.test.ts @@ -310,3 +310,99 @@ describe('outbound-form-adapter: round-trip', () => { expect(form.protocol).toBe('vless'); }); }); + +describe('outbound-form-adapter: xhttp xmux toggle', () => { + const xmuxWire = { + protocol: 'vless', + tag: 'out-xhttp', + settings: { + address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', + flow: '', encryption: 'none', + }, + streamSettings: { + network: 'xhttp', + security: 'none', + xhttpSettings: { + path: '/', host: '', mode: '', + xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000', + xmux: { maxConcurrency: '11', maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' }, + }, + }, + }; + + it('derives enableXmux from a saved xmux object and backfills missing knobs', () => { + const form = rawOutboundToFormValues(xmuxWire); + const stream = form.streamSettings as Record; + const xhttp = stream.xhttpSettings as Record; + expect(xhttp.enableXmux).toBe(true); + expect(xhttp.xmux).toMatchObject({ + maxConcurrency: '11', + maxConnections: '1', + hMaxRequestTimes: '1', + hMaxReusableSecs: '1', + cMaxReuseTimes: 0, + hKeepAlivePeriod: 0, + }); + }); + + it('round-trips xmux on save and strips the UI-only enableXmux flag', () => { + const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire)); + const xhttp = (back.streamSettings as Record).xhttpSettings as Record; + expect(xhttp).not.toHaveProperty('enableXmux'); + expect(xhttp.xmux).toMatchObject({ maxConcurrency: '11', maxConnections: '1' }); + }); + + it('drops xmux on save when the toggle is off', () => { + const form = rawOutboundToFormValues(xmuxWire); + const xhttp = (form.streamSettings as Record).xhttpSettings as Record; + xhttp.enableXmux = false; + const back = formValuesToWirePayload(form); + const wireXhttp = (back.streamSettings as Record).xhttpSettings as Record; + expect(wireXhttp).not.toHaveProperty('xmux'); + }); +}); + +describe('outbound-form-adapter: full optional-block round-trip', () => { + const wire = { + protocol: 'vless', + settings: { + address: '1', port: 443, id: '1', flow: '', encryption: 'none', + reverse: { + tag: '1', + sniffing: { + enabled: true, + destOverride: ['http', 'tls', 'quic', 'fakedns'], + metadataOnly: true, + routeOnly: true, + ipsExcluded: ['1'], + domainsExcluded: ['1'], + }, + }, + }, + tag: '1', + streamSettings: { + network: 'tcp', + tcpSettings: { header: { type: 'http', request: { version: '1.1', method: 'GET', path: ['/'], headers: { '1': ['1'] } }, response: { version: '1.1', status: '200', reason: 'OK', headers: { '1': ['1'] } } } }, + security: 'none', + sockopt: { tcpFastOpen: true, customSockopt: [{ type: 'int', level: '6', opt: '1', value: '1' }] }, + finalmask: { tcp: [{ type: 'fragment', settings: { packets: '1-3', length: '1', delay: '1', maxSplit: '1' } }] }, + }, + sendThrough: '1', + mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' }, + }; + + it('preserves sockopt, finalmask, mux, and reverse excludes', () => { + const back = formValuesToWirePayload(rawOutboundToFormValues(wire)); + const settings = back.settings as Record; + const sniffing = (settings.reverse as Record).sniffing as Record; + expect(sniffing.ipsExcluded).toEqual(['1']); + expect(sniffing.domainsExcluded).toEqual(['1']); + + const stream = back.streamSettings as Record; + expect(stream.sockopt).toMatchObject({ tcpFastOpen: true }); + expect((stream.sockopt as Record).customSockopt).toHaveLength(1); + expect(stream.finalmask).toMatchObject({ tcp: [{ type: 'fragment' }] }); + + expect(back.mux).toMatchObject({ enabled: true }); + }); +});