diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx index e48994d5..b42ffca4 100644 --- a/frontend/src/pages/xray/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/OutboundFormModal.tsx @@ -99,6 +99,11 @@ const NETWORK_OPTIONS: { value: string; label: string }[] = [ { value: 'xhttp', label: 'XHTTP' }, ]; +// Hysteria appends an extra `hysteria` network branch to the selector +// — only when the parent protocol is hysteria. Wire-side this matches +// the legacy modal's `isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS`. +const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' }; + // Per-network bootstrap. Mirrors the legacy class constructors so the // initial state for each transport matches what xray-core expects. function newStreamSlice(network: string): Record { @@ -136,6 +141,24 @@ function newStreamSlice(network: string): Record { xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000', }, }; + case 'hysteria': + return { + network: 'hysteria', + hysteriaSettings: { + version: 2, + auth: '', + congestion: '', + up: '0', + down: '0', + initStreamReceiveWindow: 8388608, + maxStreamReceiveWindow: 8388608, + initConnectionReceiveWindow: 20971520, + maxConnectionReceiveWindow: 20971520, + maxIdleTimeout: 30, + keepAlivePeriod: 2, + disablePathMTUDiscovery: false, + }, + }; default: return { network: 'tcp', tcpSettings: { header: { type: 'none' } } }; } @@ -1053,7 +1076,11 @@ export default function OutboundFormModal({ + + + + + + + + + + {() => { + const udphop = form.getFieldValue([ + 'streamSettings', 'hysteriaSettings', 'udphop', + ]) as { port?: string } | undefined; + return ( + + form.setFieldValue( + ['streamSettings', 'hysteriaSettings', 'udphop'], + checked + ? { port: '', intervalMin: 30, intervalMax: 30 } + : undefined, + ) + } + /> + ); + }} + + + + {() => { + const udphop = form.getFieldValue([ + 'streamSettings', 'hysteriaSettings', 'udphop', + ]) as { port?: string } | undefined; + if (!udphop) return null; + return ( + <> + + + + + + + + + + + ); + }} + + + + + + + + + + +
+ Receive-window tuning (init/maxStreamReceiveWindow, + init/maxConnectionReceiveWindow) is rarely changed + — edit via the JSON tab if needed. +
+ + )} )} diff --git a/frontend/src/schemas/protocols/stream/hysteria.ts b/frontend/src/schemas/protocols/stream/hysteria.ts new file mode 100644 index 00000000..2a8ff0d3 --- /dev/null +++ b/frontend/src/schemas/protocols/stream/hysteria.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +// Hysteria stream transport — the hysteria-specific knobs that ride +// alongside the connect target on outbound (and the inbound side too, +// where the listening peer needs matching auth / congestion / obfs). +// Wire shape mirrors xray-core's HysteriaConfig, with udphop nested +// when port-hopping is on and omitted otherwise. + +export const HysteriaUdphopSchema = z.object({ + port: z.string().default(''), + intervalMin: z.number().int().min(1).default(30), + intervalMax: z.number().int().min(1).default(30), +}); +export type HysteriaUdphop = z.infer; + +// `congestion` is `''` (BBR, the default) or `'brutal'`. Both empty and +// missing are equivalent on the wire so we accept either. +export const HysteriaCongestionSchema = z.union([z.literal(''), z.literal('brutal')]); + +export const HysteriaStreamSettingsSchema = z.object({ + version: z.literal(2).default(2), + auth: z.string().default(''), + congestion: HysteriaCongestionSchema.default(''), + // up / down are dash-separated bandwidth strings like '100 mbps' / '1 gbps'. + // The panel stores them as free-form strings and Xray parses on the + // server side; no client-side validation. + up: z.string().default('0'), + down: z.string().default('0'), + udphop: HysteriaUdphopSchema.optional(), + initStreamReceiveWindow: z.number().int().min(0).default(8388608), + maxStreamReceiveWindow: z.number().int().min(0).default(8388608), + initConnectionReceiveWindow: z.number().int().min(0).default(20971520), + maxConnectionReceiveWindow: z.number().int().min(0).default(20971520), + maxIdleTimeout: z.number().int().min(1).default(30), + keepAlivePeriod: z.number().int().min(1).default(2), + disablePathMTUDiscovery: z.boolean().default(false), +}); +export type HysteriaStreamSettings = z.infer; diff --git a/frontend/src/schemas/protocols/stream/index.ts b/frontend/src/schemas/protocols/stream/index.ts index 25acf9ab..55215f41 100644 --- a/frontend/src/schemas/protocols/stream/index.ts +++ b/frontend/src/schemas/protocols/stream/index.ts @@ -4,6 +4,7 @@ import { ExternalProxyEntrySchema } from './external-proxy'; import { FinalMaskStreamSettingsSchema } from './finalmask'; import { GrpcStreamSettingsSchema } from './grpc'; import { HttpUpgradeStreamSettingsSchema } from './httpupgrade'; +import { HysteriaStreamSettingsSchema } from './hysteria'; import { KcpStreamSettingsSchema } from './kcp'; import { SockoptStreamSettingsSchema } from './sockopt'; import { TcpStreamSettingsSchema } from './tcp'; @@ -14,13 +15,16 @@ export * from './external-proxy'; export * from './finalmask'; export * from './grpc'; export * from './httpupgrade'; +export * from './hysteria'; export * from './kcp'; export * from './sockopt'; export * from './tcp'; export * from './ws'; export * from './xhttp'; -export const NetworkSchema = z.enum(['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp']); +export const NetworkSchema = z.enum([ + 'tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp', 'hysteria', +]); export type Network = z.infer; // Tagged-wrapper DU on `network`. The wire shape uses an asymmetric per- @@ -28,6 +32,10 @@ export type Network = z.infer; // `settings` object — same pattern Xray ships and the panel's StreamSettings // class flattens via toJson. Each branch carries only the matching key so // fixtures round-trip byte-identical. +// +// `hysteria` is only valid when the parent protocol is hysteria — the +// network selector hides it for other protocols. xray-core enforces +// the constraint server-side too. export const NetworkSettingsSchema = z.discriminatedUnion('network', [ z.object({ network: z.literal('tcp'), tcpSettings: TcpStreamSettingsSchema }), z.object({ network: z.literal('kcp'), kcpSettings: KcpStreamSettingsSchema }), @@ -35,6 +43,7 @@ export const NetworkSettingsSchema = z.discriminatedUnion('network', [ z.object({ network: z.literal('grpc'), grpcSettings: GrpcStreamSettingsSchema }), z.object({ network: z.literal('httpupgrade'), httpupgradeSettings: HttpUpgradeStreamSettingsSchema }), z.object({ network: z.literal('xhttp'), xhttpSettings: XHttpStreamSettingsSchema }), + z.object({ network: z.literal('hysteria'), hysteriaSettings: HysteriaStreamSettingsSchema }), ]); export type NetworkSettings = z.infer;