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:
MHSanaei
2026-05-26 13:19:08 +02:00
parent f4a49862a0
commit e01acae843
3 changed files with 343 additions and 6 deletions

View File

@@ -554,6 +554,22 @@ function loopbackToWire(s: LoopbackOutboundFormSettings) {
const MUX_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'shadowsocks', 'http', 'socks']);
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 {
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
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).
if (values.streamSettings) {
if (STREAM_PROTOCOLS.has(values.protocol)) {
result.streamSettings = values.streamSettings;
result.streamSettings = stripUiOnlyStreamFields(values.streamSettings);
} else {
const sockopt = (values.streamSettings as { sockopt?: unknown }).sockopt;
if (sockopt) result.streamSettings = { sockopt };

View File

@@ -1306,11 +1306,308 @@ export default function OutboundFormModal({
>
<Input />
</Form.Item>
<div style={{ marginTop: 4, opacity: 0.6, fontStyle: 'italic' }}>
XHTTP advanced fields (XMUX, sequence/session placement,
padding obfs) are still being migrated edit them via
the JSON tab.
</div>
<Form.Item
label="Headers"
name={['streamSettings', 'xhttpSettings', 'headers']}
>
<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>
</>
)}

View File

@@ -12,6 +12,19 @@ export type XHttpMode = z.infer<typeof XHttpModeSchema>;
// server ignores them at runtime. Outbound has additional fields (uplinkChunk
// sizes, noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) which
// 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({
path: z.string().default('/'),
host: z.string().default(''),
@@ -35,5 +48,16 @@ export const XHttpStreamSettingsSchema = z.object({
serverMaxHeaderBytes: z.number().int().min(0).default(0),
uplinkHTTPMethod: z.string().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>;