feat(frontend): Hysteria stream sub-form (schema branch + outbound UI)

Add the 7th branch to NetworkSettingsSchema for Hysteria transport.

schemas/protocols/stream/hysteria.ts:
- HysteriaStreamSettingsSchema covers the full wire shape: version=2,
  auth, congestion (''|'brutal'), up/down bandwidth strings, optional
  udphop sub-object for port-hopping, receive-window tuning fields,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery.

schemas/protocols/stream/index.ts:
- NetworkSchema gains 'hysteria'.
- NetworkSettingsSchema gains the 7th branch
  { network: 'hysteria', hysteriaSettings: HysteriaStreamSettingsSchema }.

OutboundFormModal.tsx:
- NETWORK_OPTIONS keeps the 6 standard transports for non-hysteria
  protocols; when protocol === 'hysteria', a 7th option is appended
  (matches the legacy [...NETWORKS, 'hysteria'] gate).
- newStreamSlice handles the 'hysteria' case with sensible defaults
  matching the legacy HysteriaStreamSettings constructor.
- New sub-form when network === 'hysteria': 8 common fields (auth,
  congestion, up, down, udphop Switch + 3 nested fields when on,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery).
- Receive-window tuning fields are still edit-via-JSON (rarely
  touched + would clutter the form).
This commit is contained in:
MHSanaei
2026-05-26 13:10:37 +02:00
parent 7442486a58
commit 19204f9e04
3 changed files with 195 additions and 2 deletions

View File

@@ -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<string, unknown> {
@@ -136,6 +141,24 @@ function newStreamSlice(network: string): Record<string, unknown> {
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({
<Select
value={network}
onChange={onNetworkChange}
options={NETWORK_OPTIONS}
options={
protocol === 'hysteria'
? [...NETWORK_OPTIONS, HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
@@ -1286,6 +1313,125 @@ export default function OutboundFormModal({
</div>
</>
)}
{network === 'hysteria' && (
<>
<Form.Item
label="Auth password"
name={['streamSettings', 'hysteriaSettings', 'auth']}
>
<Input />
</Form.Item>
<Form.Item
label="Congestion"
name={['streamSettings', 'hysteriaSettings', 'congestion']}
>
<Select
options={[
{ value: '', label: 'BBR (auto)' },
{ value: 'brutal', label: 'Brutal' },
]}
/>
</Form.Item>
<Form.Item
label="Upload"
name={['streamSettings', 'hysteriaSettings', 'up']}
>
<Input placeholder="100 mbps" />
</Form.Item>
<Form.Item
label="Download"
name={['streamSettings', 'hysteriaSettings', 'down']}
>
<Input placeholder="100 mbps" />
</Form.Item>
<Form.Item label="UDP hop">
<Form.Item
shouldUpdate
noStyle
>
{() => {
const udphop = form.getFieldValue([
'streamSettings', 'hysteriaSettings', 'udphop',
]) as { port?: string } | undefined;
return (
<Switch
checked={!!udphop}
onChange={(checked) =>
form.setFieldValue(
['streamSettings', 'hysteriaSettings', 'udphop'],
checked
? { port: '', intervalMin: 30, intervalMax: 30 }
: undefined,
)
}
/>
);
}}
</Form.Item>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const udphop = form.getFieldValue([
'streamSettings', 'hysteriaSettings', 'udphop',
]) as { port?: string } | undefined;
if (!udphop) return null;
return (
<>
<Form.Item
label="UDP hop port"
name={['streamSettings', 'hysteriaSettings', 'udphop', 'port']}
>
<Input placeholder="1145-1919" />
</Form.Item>
<Form.Item
label="UDP hop interval min (s)"
name={[
'streamSettings', 'hysteriaSettings',
'udphop', 'intervalMin',
]}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
label="UDP hop interval max (s)"
name={[
'streamSettings', 'hysteriaSettings',
'udphop', 'intervalMax',
]}
>
<InputNumber min={1} />
</Form.Item>
</>
);
}}
</Form.Item>
<Form.Item
label="Max idle (s)"
name={['streamSettings', 'hysteriaSettings', 'maxIdleTimeout']}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
label="Keep alive (s)"
name={['streamSettings', 'hysteriaSettings', 'keepAlivePeriod']}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
label="Disable Path MTU"
name={['streamSettings', 'hysteriaSettings', 'disablePathMTUDiscovery']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<div style={{ marginTop: 4, opacity: 0.6, fontStyle: 'italic' }}>
Receive-window tuning (init/maxStreamReceiveWindow,
init/maxConnectionReceiveWindow) is rarely changed
edit via the JSON tab if needed.
</div>
</>
)}
</>
)}

View File

@@ -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<typeof HysteriaUdphopSchema>;
// `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<typeof HysteriaStreamSettingsSchema>;

View File

@@ -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<typeof NetworkSchema>;
// Tagged-wrapper DU on `network`. The wire shape uses an asymmetric per-
@@ -28,6 +32,10 @@ export type Network = z.infer<typeof NetworkSchema>;
// `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<typeof NetworkSettingsSchema>;