feat(frontend): inbound Hysteria stream sub-form (auth + udpIdleTimeout + masquerade)

Restore the inbound side of Hysteria stream configuration that was
previously hidden — the legacy modal exposed these knobs but the
Pattern A rewrite gated them out.

schemas/protocols/stream/hysteria.ts:
- HysteriaMasqueradeSchema covers the inbound-only masquerade wire
  shape: type ('proxy'|'file'|'string'), dir, url, rewriteHost,
  insecure, content, headers, statusCode. The three masquerade types
  cover the spectrum: reverse-proxy upstream, serve static files, or
  return a fixed string body.
- HysteriaStreamSettingsSchema gains 3 inbound-side optional fields:
  protocol, udpIdleTimeout, masquerade. Outbound side is untouched
  (the legacy class accepted both wire shapes via the same struct).

InboundFormModal.tsx:
- New hysteria stream sub-form section in streamTab, gated by
  protocol === HYSTERIA. Fields: version (disabled, locked to 2),
  auth, udpIdleTimeout, masquerade Switch + nested type-Select with
  three conditional sub-blocks (proxy URL+rewriteHost+insecure,
  file dir, string statusCode+body+headers).
- onValuesChange cascade: switching TO hysteria seeds streamSettings
  with the hysteria branch (forcing network='hysteria' + TLS); switching
  AWAY from hysteria snaps back to TCP so the standard network
  selector has a valid starting point.

masquerade headers use the HeaderMapEditor v1 component.
This commit is contained in:
MHSanaei 2026-05-26 13:44:00 +02:00
parent 9de527b35f
commit 5c902ca298
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 183 additions and 0 deletions

View file

@ -510,6 +510,28 @@ export default function InboundFormModal({
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
form.setFieldValue('nodeId', null);
}
// Hysteria uses its dedicated transport — force the network branch
// so the stream tab renders the hysteria sub-form, not the leftover
// tcpSettings from the previous protocol. When leaving hysteria,
// snap back to TCP so the standard network selector has a valid
// starting point.
if (next === Protocols.HYSTERIA) {
form.setFieldValue('streamSettings', {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2,
auth: '',
udpIdleTimeout: 60,
},
tlsSettings: {},
});
} else {
const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
if (current?.network === 'hysteria') {
form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
}
}
}
};
@ -1198,6 +1220,141 @@ export default function InboundFormModal({
</Form.Item>
)}
{/* Inbound Hysteria stream sub-form. The transport for hysteria
isn't user-selectable (always 'hysteria'), so the network
dropdown is hidden above. Fields here mirror the legacy
HysteriaStreamSettings inbound class: version is locked to 2,
auth + udpIdleTimeout are required, masquerade is an optional
sub-object that lets xray-core disguise the listener as an
HTTP server when probed. */}
{protocol === Protocols.HYSTERIA && (
<>
<Form.Item
label="Version"
name={['streamSettings', 'hysteriaSettings', 'version']}
>
<InputNumber min={2} max={2} disabled />
</Form.Item>
<Form.Item
label="Auth password"
name={['streamSettings', 'hysteriaSettings', 'auth']}
>
<Input />
</Form.Item>
<Form.Item
label="UDP idle timeout (s)"
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="Masquerade">
<Form.Item shouldUpdate noStyle>
{() => {
const m = form.getFieldValue([
'streamSettings', 'hysteriaSettings', 'masquerade',
]);
return (
<Switch
checked={!!m}
onChange={(checked) =>
form.setFieldValue(
['streamSettings', 'hysteriaSettings', 'masquerade'],
checked
? {
type: 'proxy', dir: '', url: '',
rewriteHost: false, insecure: false,
content: '', headers: {}, statusCode: 0,
}
: undefined,
)
}
/>
);
}}
</Form.Item>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const m = form.getFieldValue([
'streamSettings', 'hysteriaSettings', 'masquerade',
]) as { type?: string } | undefined;
if (!m) return null;
return (
<>
<Form.Item
label="Type"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'type']}
>
<Select
options={[
{ value: 'proxy', label: 'proxy (reverse proxy)' },
{ value: 'file', label: 'file (serve directory)' },
{ value: 'string', label: 'string (fixed body)' },
]}
/>
</Form.Item>
{m.type === 'proxy' && (
<>
<Form.Item
label="Upstream URL"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'url']}
>
<Input placeholder="https://www.example.com" />
</Form.Item>
<Form.Item
label="Rewrite Host"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'rewriteHost']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="Skip TLS verify"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'insecure']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
{m.type === 'file' && (
<Form.Item
label="Directory"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'dir']}
>
<Input placeholder="/var/www/html" />
</Form.Item>
)}
{m.type === 'string' && (
<>
<Form.Item
label="Status code"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'statusCode']}
>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label="Body"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'content']}
>
<Input.TextArea autoSize={{ minRows: 3 }} />
</Form.Item>
<Form.Item
label="Headers"
name={['streamSettings', 'hysteriaSettings', 'masquerade', 'headers']}
>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
</>
)}
{network === 'tcp' && (
<>
<Form.Item

View file

@ -17,7 +17,26 @@ export type HysteriaUdphop = z.infer<typeof HysteriaUdphopSchema>;
// missing are equivalent on the wire so we accept either.
export const HysteriaCongestionSchema = z.union([z.literal(''), z.literal('brutal')]);
// Inbound-only masquerade sub-object. Xray's hysteria inbound can disguise
// itself as an HTTP server by serving static files (`type: 'file'`),
// reverse-proxying upstream traffic (`type: 'proxy'`), or returning a
// fixed string body (`type: 'string'`). Fields are loose-typed strings
// because the panel writes them as free-form input.
export const HysteriaMasqueradeSchema = z.object({
type: z.enum(['proxy', 'file', 'string']).default('proxy'),
dir: z.string().default(''),
url: z.string().default(''),
rewriteHost: z.boolean().default(false),
insecure: z.boolean().default(false),
content: z.string().default(''),
headers: z.record(z.string(), z.string()).default({}),
statusCode: z.number().int().min(0).default(0),
});
export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
export const HysteriaStreamSettingsSchema = z.object({
// Outbound-side fields. The version field is shared with inbound and
// typically locked to 2.
version: z.literal(2).default(2),
auth: z.string().default(''),
congestion: HysteriaCongestionSchema.default(''),
@ -34,5 +53,12 @@ export const HysteriaStreamSettingsSchema = z.object({
maxIdleTimeout: z.number().int().min(1).default(30),
keepAlivePeriod: z.number().int().min(1).default(2),
disablePathMTUDiscovery: z.boolean().default(false),
// Inbound-side fields. xray-core's HysteriaConfig accepts both sets in
// the same struct; outbound emits the bandwidth/udphop block, inbound
// emits the protocol/udpIdleTimeout/masquerade block. The panel can
// round-trip both shapes through this single schema.
protocol: z.string().optional(),
udpIdleTimeout: z.number().int().min(1).optional(),
masquerade: HysteriaMasqueradeSchema.optional(),
});
export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;