diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index dfc204f1..0333c19a 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -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({ )} + {/* 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 && ( + <> + + + + + + + + + + + + + {() => { + const m = form.getFieldValue([ + 'streamSettings', 'hysteriaSettings', 'masquerade', + ]); + return ( + + form.setFieldValue( + ['streamSettings', 'hysteriaSettings', 'masquerade'], + checked + ? { + type: 'proxy', dir: '', url: '', + rewriteHost: false, insecure: false, + content: '', headers: {}, statusCode: 0, + } + : undefined, + ) + } + /> + ); + }} + + + + {() => { + const m = form.getFieldValue([ + 'streamSettings', 'hysteriaSettings', 'masquerade', + ]) as { type?: string } | undefined; + if (!m) return null; + return ( + <> + + + + + + + + + + + )} + {m.type === 'file' && ( + + + + )} + {m.type === 'string' && ( + <> + + + + + + + + + + + )} + + ); + }} + + + )} + {network === 'tcp' && ( <> ; // 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; + 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;