fix(outbounds): persist optional blocks and fix stale edit reopen
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run

- derive XMUX toggle from saved xmux on load, seed defaults on enable,
  and drop xmux when disabled (#4654)
- save the JSON tab straight from parsed text so sockopt, finalmask (TCP
  masks), mux, and reverse excludes round-trip instead of being dropped
  by the form-store bounce
- remove the redundant Host/Path fields from HTTP obfuscation that fought
  the request.headers editor over the same form path
- rebuild the outbounds table columns on row content change (rows, not
  rows.length) so a re-opened edited outbound shows fresh values
- add adapter round-trip regression tests

Closes #4654
This commit is contained in:
MHSanaei 2026-05-29 19:10:31 +02:00
parent 62c293e034
commit 8c30ddbfd9
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 166 additions and 59 deletions

View file

@ -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"

View file

@ -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"

View file

@ -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;

View file

@ -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<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} 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({
>
<Input placeholder="1.1" />
</Form.Item>
<Form.Item
label={t('host')}
name={[
'streamSettings',
'tcpSettings',
'header',
'request',
'headers',
'Host',
]}
normalize={(v: unknown) =>
typeof v === 'string'
? v.split(',').map((s) => s.trim()).filter(Boolean)
: Array.isArray(v) ? v : []
}
getValueProps={(v: unknown) => ({
value: Array.isArray(v) ? v.join(',') : '',
})}
>
<Input placeholder="example.com,cdn.example.com" />
</Form.Item>
<Form.Item
label={t('path')}
name={[
'streamSettings',
'tcpSettings',
'header',
'request',
'path',
]}
normalize={(v: unknown) =>
typeof v === 'string'
? v.split(',').map((s) => s.trim()).filter(Boolean)
: Array.isArray(v) ? v : ['/']
}
getValueProps={(v: unknown) => ({
value: Array.isArray(v) ? v.join(',') : '/',
})}
>
<Input placeholder="/,/api,/static" />
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.requestHeaders')}
name={[
@ -1676,7 +1667,7 @@ export default function OutboundFormModal({
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
valuePropName="checked"
>
<Switch />
<Switch onChange={onXmuxToggle} />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {

View file

@ -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 (

View file

@ -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<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
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<string, unknown>).xhttpSettings as Record<string, unknown>;
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<string, unknown>).xhttpSettings as Record<string, unknown>;
xhttp.enableXmux = false;
const back = formValuesToWirePayload(form);
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
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<string, unknown>;
const sniffing = (settings.reverse as Record<string, unknown>).sniffing as Record<string, unknown>;
expect(sniffing.ipsExcluded).toEqual(['1']);
expect(sniffing.domainsExcluded).toEqual(['1']);
const stream = back.streamSettings as Record<string, unknown>;
expect(stream.sockopt).toMatchObject({ tcpFastOpen: true });
expect((stream.sockopt as Record<string, unknown>).customSockopt).toHaveLength(1);
expect(stream.finalmask).toMatchObject({ tcp: [{ type: 'fragment' }] });
expect(back.mux).toMatchObject({ enabled: true });
});
});