mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
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
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:
parent
62c293e034
commit
8c30ddbfd9
6 changed files with 166 additions and 59 deletions
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{() => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue