mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
feat(frontend): XHTTP advanced fields on outbound modal
Replace the 'edit via JSON' deferred-features hint with the full XHTTP sub-form matching the legacy modal's XhttpFields helper. schemas/protocols/stream/xhttp.ts: - New XHttpXmuxSchema: 6 connection-multiplexing knobs (maxConcurrency, maxConnections, cMaxReuseTimes, hMaxRequestTimes, hMaxReusableSecs, hKeepAlivePeriod). - XHttpStreamSettingsSchema gains 5 outbound-only fields and one UI-only toggle: scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader, xmux, enableXmux. outbound-form-adapter.ts: - New stripUiOnlyStreamFields() drops xhttpSettings.enableXmux on the way to wire so the panel never embeds the UI toggle into the saved config. xray-core ignores unknown fields anyway, but the panel reads back its own emitted JSON, so a clean wire shape matters. OutboundFormModal.tsx: - Headers editor (HeaderMapEditor v1) for xhttpSettings.headers. - Padding obfs Switch + 4 conditional fields (key/header/placement/ method) when on. - Uplink HTTP method Select with GET disabled outside packet-up. - Session placement + session key (key shown when placement != path). - Sequence placement + sequence key (same pattern). - packet-up mode: scMinPostsIntervalMs, scMaxEachPostBytes, uplink data placement + key + chunk size (key/chunk-size shown when placement != body). - stream-up / stream-one mode: noGRPCHeader Switch. - XMUX Switch + 6 nested fields when on.
This commit is contained in:
parent
f4a49862a0
commit
e01acae843
3 changed files with 343 additions and 6 deletions
|
|
@ -554,6 +554,22 @@ function loopbackToWire(s: LoopbackOutboundFormSettings) {
|
||||||
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
|
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
|
||||||
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
|
const STREAM_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'hysteria']);
|
||||||
|
|
||||||
|
// Strip UI-only fields the form layered into streamSettings (e.g. the
|
||||||
|
// XHTTP modal's enableXmux toggle that controls section visibility but
|
||||||
|
// has no meaning on the wire). xray-core would ignore unknown fields
|
||||||
|
// anyway but the panel reads back its own emitted JSON, so we keep
|
||||||
|
// the wire shape clean.
|
||||||
|
function stripUiOnlyStreamFields(stream: unknown): Raw {
|
||||||
|
const next = { ...(stream as Raw) };
|
||||||
|
const xh = next.xhttpSettings;
|
||||||
|
if (xh && typeof xh === 'object') {
|
||||||
|
const cleaned = { ...(xh as Raw) };
|
||||||
|
delete cleaned.enableXmux;
|
||||||
|
next.xhttpSettings = cleaned;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function muxAllowed(values: OutboundFormValues): boolean {
|
function muxAllowed(values: OutboundFormValues): boolean {
|
||||||
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
||||||
const flow = values.protocol === 'vless'
|
const flow = values.protocol === 'vless'
|
||||||
|
|
@ -596,7 +612,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
|
||||||
// still emit just `sockopt` if that key is present (legacy behavior).
|
// still emit just `sockopt` if that key is present (legacy behavior).
|
||||||
if (values.streamSettings) {
|
if (values.streamSettings) {
|
||||||
if (STREAM_PROTOCOLS.has(values.protocol)) {
|
if (STREAM_PROTOCOLS.has(values.protocol)) {
|
||||||
result.streamSettings = values.streamSettings;
|
result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
|
||||||
} else {
|
} else {
|
||||||
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
|
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
|
||||||
if (sockopt) result.streamSettings = { sockopt };
|
if (sockopt) result.streamSettings = { sockopt };
|
||||||
|
|
|
||||||
|
|
@ -1306,11 +1306,308 @@ export default function OutboundFormModal({
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div style={{ marginTop: 4, opacity: 0.6, fontStyle: 'italic' }}>
|
<Form.Item
|
||||||
XHTTP advanced fields (XMUX, sequence/session placement,
|
label="Headers"
|
||||||
padding obfs) are still being migrated — edit them via
|
name={['streamSettings', 'xhttpSettings', 'headers']}
|
||||||
the JSON tab.
|
>
|
||||||
</div>
|
<HeaderMapEditor mode="v1" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Padding obfs sub-section: gated by a Switch.
|
||||||
|
When on, four extra knobs (key/header/placement/
|
||||||
|
method) tune how Xray injects random padding to
|
||||||
|
disguise the post body shape. */}
|
||||||
|
<Form.Item
|
||||||
|
label="Padding obfs mode"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const obfs = !!form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
|
||||||
|
]);
|
||||||
|
if (!obfs) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Padding key"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
|
||||||
|
>
|
||||||
|
<Input placeholder="x_padding" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Padding header"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
|
||||||
|
>
|
||||||
|
<Input placeholder="X-Padding" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Padding placement"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Default (queryInHeader)' },
|
||||||
|
{ value: 'queryInHeader', label: 'queryInHeader' },
|
||||||
|
{ value: 'header', label: 'header' },
|
||||||
|
{ value: 'cookie', label: 'cookie' },
|
||||||
|
{ value: 'query', label: 'query' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Padding method"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Default (repeat-x)' },
|
||||||
|
{ value: 'repeat-x', label: 'repeat-x' },
|
||||||
|
{ value: 'tokenish', label: 'tokenish' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Uplink HTTP method"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
|
||||||
|
>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const mode = form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'mode',
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Default (POST)' },
|
||||||
|
{ value: 'POST', label: 'POST' },
|
||||||
|
{ value: 'PUT', label: 'PUT' },
|
||||||
|
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Session + sequence + uplinkData placements:
|
||||||
|
three orthogonal slots Xray uses to thread
|
||||||
|
request metadata through the transport
|
||||||
|
(path / header / cookie / query). Key field
|
||||||
|
only matters when placement is not 'path'. */}
|
||||||
|
<Form.Item
|
||||||
|
label="Session placement"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Default (path)' },
|
||||||
|
{ value: 'path', label: 'path' },
|
||||||
|
{ value: 'header', label: 'header' },
|
||||||
|
{ value: 'cookie', label: 'cookie' },
|
||||||
|
{ value: 'query', label: 'query' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const placement = form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'sessionPlacement',
|
||||||
|
]);
|
||||||
|
if (!placement || placement === 'path') return null;
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="Session key"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'sessionKey']}
|
||||||
|
>
|
||||||
|
<Input placeholder="x_session" />
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Sequence placement"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Default (path)' },
|
||||||
|
{ value: 'path', label: 'path' },
|
||||||
|
{ value: 'header', label: 'header' },
|
||||||
|
{ value: 'cookie', label: 'cookie' },
|
||||||
|
{ value: 'query', label: 'query' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const placement = form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'seqPlacement',
|
||||||
|
]);
|
||||||
|
if (!placement || placement === 'path') return null;
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="Sequence key"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'seqKey']}
|
||||||
|
>
|
||||||
|
<Input placeholder="x_seq" />
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Mode-conditional sub-sections. */}
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const mode = form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'mode',
|
||||||
|
]);
|
||||||
|
if (mode !== 'packet-up') return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Min upload interval (ms)"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
|
||||||
|
>
|
||||||
|
<Input placeholder="30" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Max upload size (bytes)"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
|
||||||
|
>
|
||||||
|
<Input placeholder="1000000" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Uplink data placement"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Default (body)' },
|
||||||
|
{ value: 'body', label: 'body' },
|
||||||
|
{ value: 'header', label: 'header' },
|
||||||
|
{ value: 'cookie', label: 'cookie' },
|
||||||
|
{ value: 'query', label: 'query' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const place = form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
|
||||||
|
]);
|
||||||
|
if (!place || place === 'body') return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Uplink data key"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
|
||||||
|
>
|
||||||
|
<Input placeholder="x_data" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Uplink chunk size"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
placeholder="0 (unlimited)"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
const mode = form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'mode',
|
||||||
|
]);
|
||||||
|
if (mode !== 'stream-up' && mode !== 'stream-one') return null;
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label="No gRPC header"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* XMUX is the connection-multiplexing layer
|
||||||
|
xHTTP uses to fan out parallel requests over
|
||||||
|
a small pool of upstream connections. UI-only
|
||||||
|
toggle (enableXmux) hides the 6 nested knobs
|
||||||
|
when off. */}
|
||||||
|
<Form.Item
|
||||||
|
label="XMUX"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item shouldUpdate noStyle>
|
||||||
|
{() => {
|
||||||
|
if (!form.getFieldValue([
|
||||||
|
'streamSettings', 'xhttpSettings', 'enableXmux',
|
||||||
|
])) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label="Max concurrency"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||||
|
>
|
||||||
|
<Input placeholder="16-32" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Max connections"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||||
|
>
|
||||||
|
<Input placeholder="0" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Max reuse times"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Max request times"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
|
||||||
|
>
|
||||||
|
<Input placeholder="600-900" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Max reusable secs"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
|
||||||
|
>
|
||||||
|
<Input placeholder="1800-3000" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Keep alive period"
|
||||||
|
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,19 @@ export type XHttpMode = z.infer<typeof XHttpModeSchema>;
|
||||||
// server ignores them at runtime. Outbound has additional fields (uplinkChunk
|
// server ignores them at runtime. Outbound has additional fields (uplinkChunk
|
||||||
// sizes, noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) which
|
// sizes, noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) which
|
||||||
// belong on the outbound class instead, not modeled here.
|
// belong on the outbound class instead, not modeled here.
|
||||||
|
// XMUX is the connection-multiplexing layer xHTTP uses to fan out
|
||||||
|
// parallel requests over a small pool of upstream connections. Fields
|
||||||
|
// are strings because they accept dash-range values like '16-32'.
|
||||||
|
export const XHttpXmuxSchema = z.object({
|
||||||
|
maxConcurrency: z.string().default('16-32'),
|
||||||
|
maxConnections: z.union([z.string(), z.number()]).default(0),
|
||||||
|
cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
|
||||||
|
hMaxRequestTimes: z.string().default('600-900'),
|
||||||
|
hMaxReusableSecs: z.string().default('1800-3000'),
|
||||||
|
hKeepAlivePeriod: z.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
|
||||||
|
|
||||||
export const XHttpStreamSettingsSchema = z.object({
|
export const XHttpStreamSettingsSchema = z.object({
|
||||||
path: z.string().default('/'),
|
path: z.string().default('/'),
|
||||||
host: z.string().default(''),
|
host: z.string().default(''),
|
||||||
|
|
@ -35,5 +48,16 @@ export const XHttpStreamSettingsSchema = z.object({
|
||||||
serverMaxHeaderBytes: z.number().int().min(0).default(0),
|
serverMaxHeaderBytes: z.number().int().min(0).default(0),
|
||||||
uplinkHTTPMethod: z.string().default(''),
|
uplinkHTTPMethod: z.string().default(''),
|
||||||
headers: WsHeaderMapSchema.default({}),
|
headers: WsHeaderMapSchema.default({}),
|
||||||
|
// Outbound-only fields. Server (inbound) listener ignores these. The
|
||||||
|
// panel embeds them in share-link `extra` blobs so the same xhttp
|
||||||
|
// config can roundtrip on both sides.
|
||||||
|
scMinPostsIntervalMs: z.string().default('30'),
|
||||||
|
uplinkChunkSize: z.number().int().min(0).default(0),
|
||||||
|
noGRPCHeader: z.boolean().default(false),
|
||||||
|
xmux: XHttpXmuxSchema.optional(),
|
||||||
|
// UI-only toggle controlling whether the XMUX sub-form is expanded.
|
||||||
|
// Never present on the wire — outbound modal strips it via the
|
||||||
|
// form-to-wire adapter.
|
||||||
|
enableXmux: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
export type XHttpStreamSettings = z.infer<typeof XHttpStreamSettingsSchema>;
|
export type XHttpStreamSettings = z.infer<typeof XHttpStreamSettingsSchema>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue