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": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.16.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6",
|
"swagger-ui-react": "^5.32.6",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|
@ -6004,9 +6004,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.15.1",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
|
||||||
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
|
|
@ -6026,12 +6026,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.15.1",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
|
||||||
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.15.1"
|
"react-router": "7.16.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.16.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6",
|
"swagger-ui-react": "^5.32.6",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -345,6 +346,23 @@ export interface RawOutboundRow {
|
||||||
mux?: unknown;
|
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 {
|
export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
|
||||||
const protocol = asString(raw.protocol, 'vless');
|
const protocol = asString(raw.protocol, 'vless');
|
||||||
const settings = asObject(raw.settings);
|
const settings = asObject(raw.settings);
|
||||||
|
|
@ -355,7 +373,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||||
&& typeof raw.streamSettings === 'object'
|
&& typeof raw.streamSettings === 'object'
|
||||||
&& Object.keys(raw.streamSettings as Raw).length > 0;
|
&& Object.keys(raw.streamSettings as Raw).length > 0;
|
||||||
const streamSettings = hasStream
|
const streamSettings = hasStream
|
||||||
? (raw.streamSettings as unknown as OutboundStreamFormValues)
|
? hydrateStreamForm(raw.streamSettings as Raw)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
let typed: OutboundFormSettings;
|
let typed: OutboundFormSettings;
|
||||||
|
|
@ -558,7 +576,9 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
||||||
const xh = next.xhttpSettings;
|
const xh = next.xhttpSettings;
|
||||||
if (xh && typeof xh === 'object') {
|
if (xh && typeof xh === 'object') {
|
||||||
const cleaned = { ...(xh as Raw) };
|
const cleaned = { ...(xh as Raw) };
|
||||||
|
const xmuxEnabled = cleaned.enableXmux === true;
|
||||||
delete cleaned.enableXmux;
|
delete cleaned.enableXmux;
|
||||||
|
if (!xmuxEnabled) delete cleaned.xmux;
|
||||||
next.xhttpSettings = dropEmptyStrings(cleaned);
|
next.xhttpSettings = dropEmptyStrings(cleaned);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import InputAddon from '@/components/InputAddon';
|
||||||
import JsonEditor from '@/components/JsonEditor';
|
import JsonEditor from '@/components/JsonEditor';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
import {
|
import {
|
||||||
|
XMUX_DEFAULTS,
|
||||||
formValuesToWirePayload,
|
formValuesToWirePayload,
|
||||||
rawOutboundToFormValues,
|
rawOutboundToFormValues,
|
||||||
} from '@/lib/xray/outbound-form-adapter';
|
} from '@/lib/xray/outbound-form-adapter';
|
||||||
|
|
@ -335,6 +336,14 @@ export default function OutboundFormModal({
|
||||||
form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity });
|
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 duplicateTag = useMemo(() => {
|
||||||
const myTag = tag.trim();
|
const myTag = tag.trim();
|
||||||
if (!myTag) return false;
|
if (!myTag) return false;
|
||||||
|
|
@ -392,17 +401,40 @@ export default function OutboundFormModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onOk() {
|
async function onOk() {
|
||||||
if (activeKey === '2' && !applyJsonToForm()) return;
|
let values: OutboundFormValues;
|
||||||
try {
|
if (activeKey === '2') {
|
||||||
await form.validateFields();
|
const raw = jsonText.trim();
|
||||||
} catch {
|
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;
|
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');
|
messageApi.error('Tag already used by another outbound');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const values = form.getFieldsValue(true) as OutboundFormValues;
|
|
||||||
onConfirm(formValuesToWirePayload(values));
|
onConfirm(formValuesToWirePayload(values));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1188,47 +1220,6 @@ export default function OutboundFormModal({
|
||||||
>
|
>
|
||||||
<Input placeholder="1.1" />
|
<Input placeholder="1.1" />
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
label={t('pages.inbounds.form.requestHeaders')}
|
label={t('pages.inbounds.form.requestHeaders')}
|
||||||
name={[
|
name={[
|
||||||
|
|
@ -1676,7 +1667,7 @@ export default function OutboundFormModal({
|
||||||
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
|
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
>
|
>
|
||||||
<Switch />
|
<Switch onChange={onXmuxToggle} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item shouldUpdate noStyle>
|
<Form.Item shouldUpdate noStyle>
|
||||||
{() => {
|
{() => {
|
||||||
|
|
|
||||||
|
|
@ -375,7 +375,7 @@ export default function OutboundsTab({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[t, testMode, rows.length, outboundTestStates, outboundsTraffic],
|
[t, testMode, rows, outboundTestStates, outboundsTraffic],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -310,3 +310,99 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||||
expect(form.protocol).toBe('vless');
|
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