refactor(frontend): align hysteria with new docs + drop hysteria2 protocol

Phase 2 smoke fixes on the Inbound add flow surfaced that hysteria2 was
modeled as a separate top-level protocol when it's really just hysteria
v2. The xray transports/hysteria.html docs also pin the hysteria stream
to a minimal shape (version/auth/udpIdleTimeout/masquerade) — the
previous schema carried legacy congestion/up/down/udphop/window knobs
that aren't part of the wire contract.

Hysteria2 removal:
- Drop 'hysteria2' from ProtocolSchema enum and Protocols const
- Drop hysteria2 branches from inbound/outbound discriminated unions
- Drop createDefaultHysteria2InboundSettings / OutboundSettings
- Delete schemas/protocols/inbound/hysteria2.ts and outbound/hysteria2.ts
- Drop hysteria2 case in getInboundClients / genLink (fell through to
  the hysteria handler anyway)
- Update client form modals' MULTI_CLIENT_PROTOCOLS sets
- Remove hysteria2-basic fixture + snapshot entries (14 capability
  cases, 1 protocols fixture, 1 inbound-defaults factory)
- Keep parseHysteria2Link() outbound parser since hysteria2:// is the
  share-link URI prefix for hysteria v2

Hysteria stream alignment with xtls docs:
- HysteriaStreamSettingsSchema reduced to version/auth/udpIdleTimeout/
  masquerade per transports/hysteria.html
- Masquerade type adds '' (default 404 page) and defaults to it
- Outbound form drops Congestion/Upload/Download/UDP hop/Max idle/
  Keep alive/Disable Path MTU controls and the receive-window note
- newStreamSlice('hysteria') in OutboundFormModal mirrors the trimmed
  shape; outbound-link-parser emits the trimmed shape too
- InboundFormModal Masquerade Select gains the default option

New TUN inbound schema:
- Add schemas/protocols/inbound/tun.ts with name/mtu/gateway/dns/
  userLevel/autoSystemRoutingTable/autoOutboundsInterface
- Wire into ProtocolSchema enum, InboundSettingsSchema discriminated
  union, createDefaultInboundSettings dispatcher

Other Phase 2 smoke fixes folded in:
- Tunnel portMap UI swaps Form.List for HeaderMapEditor v1 — wire
  shape is Record<string,string> and the List was producing arrays
- Hysteria onValuesChange seeds full TLS schema defaults + one
  empty certificate row (Cipher Suites/Min/Max Version/uTLS/ALPN
  were undefined before)
- HTTP/Mixed accounts Add button auto-fills user/pass with
  RandomUtil.randomLowerAndNum
- Hysteria security tab gates the 'none' radio out — TLS only
- Hysteria stream tab drops the inbound Auth password field (xray
  inbound auth is per-user via 'users', not stream-level)
- Reality onSecurityChange auto-randomizes target/serverNames/
  shortIds and fetches an X25519 keypair
- Tag and DB-side fields (up/down/total/expiryTime/
  lastTrafficResetTime/clientStats/security) gain hidden Form.Items
  so validateFields keeps them in the wire payload (rc-component
  form strips unregistered fields)
- WireGuard inbound auto-seeds one peer with generated keypair,
  allowedIPs ['10.0.0.2/32'], keepAlive 0 — matches legacy
- WireGuard peer rows separated by Divider with the Peer N title
  and a small inline remove button (titlePlacement="center")
This commit is contained in:
MHSanaei
2026-05-26 17:49:37 +02:00
parent 90e11dc0f6
commit 5a90f7e348
24 changed files with 171 additions and 528 deletions

View File

@@ -3025,7 +3025,7 @@
"tags": [
"Clients"
],
"summary": "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
"summary": "Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
"operationId": "get_panel_api_clients_links_email",
"parameters": [
{

View File

@@ -1,11 +1,11 @@
import { RandomUtil, Wireguard } from '@/utils';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { Hysteria2InboundSettings } from '@/schemas/protocols/inbound/hysteria2';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
import type { TunnelInboundSettings } from '@/schemas/protocols/inbound/tunnel';
import type { VlessClient, VlessInboundSettings } from '@/schemas/protocols/inbound/vless';
import type { VmessClient, VmessInboundSettings } from '@/schemas/protocols/inbound/vmess';
@@ -184,10 +184,6 @@ export function createDefaultHysteriaInboundSettings(
};
}
export function createDefaultHysteria2InboundSettings(): Hysteria2InboundSettings {
return { version: 2, clients: [] };
}
export function createDefaultHttpInboundSettings(): HttpInboundSettings {
return { accounts: [], allowTransparent: false };
}
@@ -209,19 +205,40 @@ export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
};
}
export function createDefaultTunInboundSettings(): TunInboundSettings {
return {
name: 'xray0',
mtu: 1500,
gateway: [],
dns: [],
userLevel: 0,
autoSystemRoutingTable: [],
autoOutboundsInterface: 'auto',
};
}
export interface WireguardInboundSeed {
mtu?: number;
secretKey?: string;
noKernelTun?: boolean;
peerPrivateKey?: string;
}
export function createDefaultWireguardInboundSettings(
seed: WireguardInboundSeed = {},
): WireguardInboundSettings {
const peerKp = seed.peerPrivateKey
? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
: Wireguard.generateKeypair();
return {
mtu: seed.mtu ?? 1420,
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
peers: [],
peers: [{
privateKey: peerKp.privateKey,
publicKey: peerKp.publicKey,
allowedIPs: ['10.0.0.2/32'],
keepAlive: 0,
}],
noKernelTun: seed.noKernelTun ?? false,
};
}
@@ -237,9 +254,9 @@ export type AnyInboundSettings =
| TrojanInboundSettings
| ShadowsocksInboundSettings
| HysteriaInboundSettings
| Hysteria2InboundSettings
| HttpInboundSettings
| MixedInboundSettings
| TunInboundSettings
| TunnelInboundSettings
| WireguardInboundSettings;
@@ -250,10 +267,10 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
case 'trojan': return createDefaultTrojanInboundSettings();
case 'shadowsocks': return createDefaultShadowsocksInboundSettings();
case 'hysteria': return createDefaultHysteriaInboundSettings();
case 'hysteria2': return createDefaultHysteria2InboundSettings();
case 'http': return createDefaultHttpInboundSettings();
case 'mixed': return createDefaultMixedInboundSettings();
case 'tunnel': return createDefaultTunnelInboundSettings();
case 'tun': return createDefaultTunInboundSettings();
case 'wireguard': return createDefaultWireguardInboundSettings();
default: return null;
}

View File

@@ -572,7 +572,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
clientAuth,
} = input;
if (inbound.protocol !== 'hysteria' && inbound.protocol !== 'hysteria2') return '';
if (inbound.protocol !== 'hysteria') return '';
const stream = inbound.streamSettings;
if (!stream || stream.security !== 'tls') return '';
@@ -707,7 +707,6 @@ export function getInboundClients(inbound: Inbound): ClientShape[] | null {
case 'trojan':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'hysteria':
case 'hysteria2':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'shadowsocks': {
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
@@ -764,7 +763,6 @@ export function genLink(input: GenLinkInput): string {
externalProxy,
});
case 'hysteria':
case 'hysteria2':
return genHysteriaLink({
inbound, address, port, remark,
clientAuth: client.auth ?? '',

View File

@@ -4,7 +4,6 @@ import type { BlackholeOutboundSettings } from '@/schemas/protocols/outbound/bla
import type { DNSOutboundSettings } from '@/schemas/protocols/outbound/dns';
import type { FreedomOutboundSettings } from '@/schemas/protocols/outbound/freedom';
import type { HttpOutboundSettings } from '@/schemas/protocols/outbound/http';
import type { Hysteria2OutboundSettings } from '@/schemas/protocols/outbound/hysteria2';
import type { HysteriaOutboundSettings } from '@/schemas/protocols/outbound/hysteria';
import type { LoopbackOutboundSettings } from '@/schemas/protocols/outbound/loopback';
import type { ShadowsocksOutboundSettings } from '@/schemas/protocols/outbound/shadowsocks';
@@ -126,17 +125,12 @@ export function createDefaultHysteriaOutboundSettings(): HysteriaOutboundSetting
return { address: '', port: 443, version: 2 };
}
export function createDefaultHysteria2OutboundSettings(): Hysteria2OutboundSettings {
return { address: '', port: 443, version: 2 };
}
export type AnyOutboundSettings =
| BlackholeOutboundSettings
| DNSOutboundSettings
| FreedomOutboundSettings
| HttpOutboundSettings
| HysteriaOutboundSettings
| Hysteria2OutboundSettings
| LoopbackOutboundSettings
| ShadowsocksOutboundSettings
| SocksOutboundSettings
@@ -167,7 +161,6 @@ export function createDefaultOutboundSettings(protocol: string): AnyOutboundSett
case 'http': return createDefaultHttpOutboundSettings();
case 'wireguard': return createDefaultWireguardOutboundSettings();
case 'hysteria': return createDefaultHysteriaOutboundSettings();
case 'hysteria2': return createDefaultHysteria2OutboundSettings();
case 'loopback': return createDefaultLoopbackOutboundSettings();
default: return null;
}

View File

@@ -363,10 +363,7 @@ export function parseHysteria2Link(link: string): Raw | null {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2, auth, congestion: '', up: '0', down: '0',
initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608,
initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520,
maxIdleTimeout: 30, keepAlivePeriod: 2, disablePathMTUDiscovery: false,
version: 2, auth, udpIdleTimeout: 60,
},
tlsSettings: {
serverName: params.get('sni') ?? '',

View File

@@ -590,7 +590,7 @@ export const sections: readonly Section[] = [
method: 'GET',
path: '/panel/api/clients/links/:email',
summary:
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
"Return every URL for one client across all attached inbounds — the same strings the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.",
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
],

View File

@@ -15,7 +15,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
]);
interface ClientBulkAddModalProps {

View File

@@ -27,7 +27,7 @@ import './ClientFormModal.css';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
]);
interface ApiMsg<T = unknown> {

View File

@@ -5,6 +5,7 @@ import {
Button,
Card,
Checkbox,
Divider,
Empty,
Form,
Input,
@@ -61,6 +62,7 @@ import {
UTLS_FINGERPRINT,
} from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
import { SniffingSchema } from '@/schemas/primitives/sniffing';
@@ -494,14 +496,46 @@ export default function InboundFormModal({
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
};
const onSecurityChange = (next: string) => {
const onSecurityChange = async (next: string) => {
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, security: next };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') cleaned.tlsSettings = TlsStreamSettingsSchema.parse({});
if (next === 'reality') cleaned.realitySettings = RealityStreamSettingsSchema.parse({});
if (next === 'tls') {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [{
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
}];
cleaned.tlsSettings = tls;
}
if (next === 'reality') {
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
const tgt = getRandomRealityTarget() as { target: string; sni: string };
reality.target = tgt.target;
reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean);
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
cleaned.realitySettings = reality;
}
form.setFieldValue('streamSettings', cleaned);
if (next === 'reality') {
try {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
const obj = msg.obj as { privateKey: string; publicKey: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
}
} catch {
// best-effort: leave keypair fields empty if server call fails
}
}
};
const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
@@ -636,15 +670,22 @@ export default function InboundFormModal({
// snap back to TCP so the standard network selector has a valid
// starting point.
if (next === Protocols.HYSTERIA) {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [{
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
}];
form.setFieldValue('streamSettings', {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2,
auth: '',
udpIdleTimeout: 60,
},
tlsSettings: {},
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
tlsSettings: tls,
});
} else {
const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
@@ -705,6 +746,14 @@ export default function InboundFormModal({
const basicTab = (
<>
<Form.Item name="tag" hidden noStyle><Input /></Form.Item>
<Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
<Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
<Form.Item name="enable" label={t('enable')} valuePropName="checked">
<Switch />
</Form.Item>
@@ -943,27 +992,34 @@ export default function InboundFormModal({
<Form.Item label="Peers">
<Button
size="small"
onClick={() => add({
publicKey: '',
allowedIPs: [],
})}
onClick={() => {
const kp = Wireguard.generateKeypair();
add({
privateKey: kp.privateKey,
publicKey: kp.publicKey,
allowedIPs: ['10.0.0.2/32'],
keepAlive: 0,
});
}}
>
<PlusOutlined /> Add peer
</Button>
</Form.Item>
{fields.map((field, idx) => (
<div key={field.key} className="wg-peer">
<Form.Item label={`Peer ${idx + 1}`}>
{fields.length > 1 && (
<Button
size="small"
danger
onClick={() => remove(field.name)}
>
<MinusOutlined />
</Button>
)}
</Form.Item>
<Divider titlePlacement="center">
<Space>
<span>Peer {idx + 1}</span>
{fields.length > 1 && (
<Button
size="small"
danger
icon={<MinusOutlined />}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Divider>
<Form.Item
name={[field.name, 'privateKey']}
label={
@@ -1118,35 +1174,9 @@ export default function InboundFormModal({
<Select.Option value="udp">UDP</Select.Option>
</Select>
</Form.Item>
<Form.List name={['settings', 'portMap']}>
{(fields, { add, remove }) => (
<>
<Form.Item label="Port map">
<Button size="small" onClick={() => add({ name: '', value: '' })}>
<PlusOutlined />
</Button>
</Form.Item>
{fields.length > 0 && (
<Form.Item wrapperCol={{ span: 24 }}>
{fields.map((field, idx) => (
<Space.Compact key={field.key} className="mb-8" block>
<InputAddon>{String(idx + 1)}</InputAddon>
<Form.Item name={[field.name, 'name']} noStyle>
<Input placeholder="5555" />
</Form.Item>
<Form.Item name={[field.name, 'value']} noStyle>
<Input placeholder="1.1.1.1:7777" />
</Form.Item>
<Button onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
))}
</Form.Item>
)}
</>
)}
</Form.List>
<Form.Item label="Port map" name={['settings', 'portMap']}>
<HeaderMapEditor mode="v1" />
</Form.Item>
<Form.Item
name={['settings', 'followRedirect']}
label="Follow redirect"
@@ -1163,7 +1193,13 @@ export default function InboundFormModal({
{(fields, { add, remove }) => (
<>
<Form.Item label="Accounts">
<Button size="small" onClick={() => add({ user: '', pass: '' })}>
<Button
size="small"
onClick={() => add({
user: RandomUtil.randomLowerAndNum(8),
pass: RandomUtil.randomLowerAndNum(12),
})}
>
<PlusOutlined /> Add
</Button>
</Form.Item>
@@ -1373,12 +1409,6 @@ export default function InboundFormModal({
>
<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']}
@@ -1400,7 +1430,7 @@ export default function InboundFormModal({
['streamSettings', 'hysteriaSettings', 'masquerade'],
checked
? {
type: 'proxy', dir: '', url: '',
type: '', dir: '', url: '',
rewriteHost: false, insecure: false,
content: '', headers: {}, statusCode: 0,
}
@@ -1426,6 +1456,7 @@ export default function InboundFormModal({
>
<Select
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
{ value: 'file', label: 'file (serve directory)' },
{ value: 'string', label: 'string (fixed body)' },
@@ -2161,6 +2192,9 @@ export default function InboundFormModal({
const securityTab = (
<>
<Form.Item name={['streamSettings', 'security']} hidden noStyle>
<Input />
</Form.Item>
<Form.Item label={t('pages.inbounds.securityTab')}>
<Form.Item
noStyle
@@ -2176,6 +2210,7 @@ export default function InboundFormModal({
const proto = getFieldValue('protocol') ?? '';
const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
const tlsOnly = proto === Protocols.HYSTERIA;
return (
<Radio.Group
value={sec}
@@ -2183,7 +2218,7 @@ export default function InboundFormModal({
disabled={!tlsOk}
onChange={(e) => onSecurityChange(e.target.value)}
>
<Radio.Button value="none">none</Radio.Button>
{!tlsOnly && <Radio.Button value="none">none</Radio.Button>}
<Radio.Button value="tls">tls</Radio.Button>
{realityOk && <Radio.Button value="reality">reality</Radio.Button>}
</Radio.Group>

View File

@@ -149,16 +149,7 @@ function newStreamSlice(network: string): Record<string, unknown> {
hysteriaSettings: {
version: 2,
auth: '',
congestion: '',
up: '0',
down: '0',
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnectionReceiveWindow: 20971520,
maxConnectionReceiveWindow: 20971520,
maxIdleTimeout: 30,
keepAlivePeriod: 2,
disablePathMTUDiscovery: false,
udpIdleTimeout: 60,
},
};
default:
@@ -1709,113 +1700,11 @@ export default function OutboundFormModal({
<Input />
</Form.Item>
<Form.Item
label="Congestion"
name={['streamSettings', 'hysteriaSettings', 'congestion']}
label="UDP idle timeout (s)"
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
>
<Select
options={[
{ value: '', label: 'BBR (auto)' },
{ value: 'brutal', label: 'Brutal' },
]}
/>
<InputNumber min={1} style={{ width: '100%' }} />
</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

@@ -7,10 +7,10 @@ export const ProtocolSchema = z.enum([
'shadowsocks',
'wireguard',
'hysteria',
'hysteria2',
'http',
'mixed',
'tunnel',
'tun',
]);
export type Protocol = z.infer<typeof ProtocolSchema>;
@@ -27,7 +27,6 @@ export const Protocols = Object.freeze({
SHADOWSOCKS: 'shadowsocks',
WIREGUARD: 'wireguard',
HYSTERIA: 'hysteria',
HYSTERIA2: 'hysteria2',
HTTP: 'http',
MIXED: 'mixed',
TUNNEL: 'tunnel',

View File

@@ -1,13 +0,0 @@
import { z } from 'zod';
import { HysteriaClientSchema } from '@/schemas/protocols/inbound/hysteria';
// hysteria2 is wire-distinct from hysteria (different parent protocol literal,
// different Go validate tag) but the panel's settings payload is structurally
// identical — same client shape, same auth-based clients. We pin `version` to
// the literal 2 here so a hysteria2 inbound can never silently downgrade.
export const Hysteria2InboundSettingsSchema = z.object({
version: z.literal(2).default(2),
clients: z.array(HysteriaClientSchema).default([]),
});
export type Hysteria2InboundSettings = z.infer<typeof Hysteria2InboundSettingsSchema>;

View File

@@ -1,11 +1,11 @@
import { z } from 'zod';
import { HttpInboundSettingsSchema } from './http';
import { Hysteria2InboundSettingsSchema } from './hysteria2';
import { HysteriaInboundSettingsSchema } from './hysteria';
import { MixedInboundSettingsSchema } from './mixed';
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
import { TrojanInboundSettingsSchema } from './trojan';
import { TunInboundSettingsSchema } from './tun';
import { TunnelInboundSettingsSchema } from './tunnel';
import { VlessInboundSettingsSchema } from './vless';
import { VmessInboundSettingsSchema } from './vmess';
@@ -13,10 +13,10 @@ import { WireguardInboundSettingsSchema } from './wireguard';
export * from './http';
export * from './hysteria';
export * from './hysteria2';
export * from './mixed';
export * from './shadowsocks';
export * from './trojan';
export * from './tun';
export * from './tunnel';
export * from './vless';
export * from './vmess';
@@ -34,9 +34,9 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksInboundSettingsSchema }),
z.object({ protocol: z.literal('wireguard'), settings: WireguardInboundSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaInboundSettingsSchema }),
z.object({ protocol: z.literal('hysteria2'), settings: Hysteria2InboundSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpInboundSettingsSchema }),
z.object({ protocol: z.literal('mixed'), settings: MixedInboundSettingsSchema }),
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
]);
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const TunInboundSettingsSchema = z.object({
name: z.string().default('xray0'),
mtu: z.number().int().min(0).default(1500),
gateway: z.array(z.string()).default([]),
dns: z.array(z.string()).default([]),
userLevel: z.number().int().min(0).default(0),
autoSystemRoutingTable: z.array(z.string()).default([]),
autoOutboundsInterface: z.string().default('auto'),
});
export type TunInboundSettings = z.infer<typeof TunInboundSettingsSchema>;

View File

@@ -1,12 +0,0 @@
import { z } from 'zod';
import { PortSchema } from '@/schemas/primitives';
// Outbound counterpart to hysteria2 — same {address, port} connect descriptor
// as hysteria, but version locked to 2.
export const Hysteria2OutboundSettingsSchema = z.object({
address: z.string().min(1),
port: PortSchema,
version: z.literal(2).default(2),
});
export type Hysteria2OutboundSettings = z.infer<typeof Hysteria2OutboundSettingsSchema>;

View File

@@ -4,7 +4,6 @@ import { BlackholeOutboundSettingsSchema } from './blackhole';
import { DNSOutboundSettingsSchema } from './dns';
import { FreedomOutboundSettingsSchema } from './freedom';
import { HttpOutboundSettingsSchema } from './http';
import { Hysteria2OutboundSettingsSchema } from './hysteria2';
import { HysteriaOutboundSettingsSchema } from './hysteria';
import { LoopbackOutboundSettingsSchema } from './loopback';
import { ShadowsocksOutboundSettingsSchema } from './shadowsocks';
@@ -19,7 +18,6 @@ export * from './dns';
export * from './freedom';
export * from './http';
export * from './hysteria';
export * from './hysteria2';
export * from './loopback';
export * from './shadowsocks';
export * from './socks';
@@ -39,7 +37,6 @@ export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }),
z.object({ protocol: z.literal('hysteria2'), settings: Hysteria2OutboundSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }),
z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }),
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundSettingsSchema }),

View File

@@ -1,29 +1,17 @@
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.
// Hysteria stream transport. Per Xray docs (transports/hysteria.html), the
// Xray implementation of Hysteria2's underlying QUIC transport keeps only
// the essentials — version, auth, udpIdleTimeout, and masquerade. The
// extended bandwidth/window/udphop knobs that earlier hysteria builds
// exposed are not part of this transport's wire shape.
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')]);
// 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.
// Inbound masquerade — Xray's hysteria inbound can disguise itself as an
// HTTP/3 server. `type` is the empty string by default (serves the default
// 404 page), and per-type config keys are only honored when their type is
// active.
export const HysteriaMasqueradeSchema = z.object({
type: z.enum(['proxy', 'file', 'string']).default('proxy'),
type: z.enum(['', 'proxy', 'file', 'string']).default(''),
dir: z.string().default(''),
url: z.string().default(''),
rewriteHost: z.boolean().default(false),
@@ -35,30 +23,9 @@ export const HysteriaMasqueradeSchema = z.object({
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(''),
// 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),
// 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(),
udpIdleTimeout: z.number().int().min(1).default(60),
masquerade: HysteriaMasqueradeSchema.optional(),
});
export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;

View File

@@ -14,13 +14,6 @@ exports[`createDefault*InboundSettings factories > hysteria (v1, defaults to v2
}
`;
exports[`createDefault*InboundSettings factories > hysteria2 1`] = `
{
"clients": [],
"version": 2,
}
`;
exports[`createDefault*InboundSettings factories > mixed 1`] = `
{
"accounts": [],
@@ -74,7 +67,16 @@ exports[`createDefault*InboundSettings factories > wireguard 1`] = `
{
"mtu": 1420,
"noKernelTun": false,
"peers": [],
"peers": [
{
"allowedIPs": [
"10.0.0.2/32",
],
"keepAlive": 0,
"privateKey": "cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==",
"publicKey": "RNa/H++60PStnhoiiU/vIuwFimZUBuIkLkbrmEoDz34=",
},
],
"secretKey": "QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=",
}
`;

View File

@@ -336,174 +336,6 @@ exports[`protocol capability predicates > hysteria-basic :: xhttp/tls 1`] = `
}
`;
exports[`protocol capability predicates > hysteria2-basic :: grpc/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: grpc/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: grpc/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: httpupgrade/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: httpupgrade/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: kcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: tcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: tcp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: tcp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: ws/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: ws/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: xhttp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: xhttp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > hysteria2-basic :: xhttp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mixed-basic :: grpc/none 1`] = `
{
"canEnableReality": false,

View File

@@ -42,29 +42,6 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`]
}
`;
exports[`InboundSettingsSchema fixtures > parses hysteria2-basic byte-stably 1`] = `
{
"protocol": "hysteria2",
"settings": {
"clients": [
{
"auth": "hyst3ria2-auth-token-XYZ",
"comment": "",
"email": "hy2-client@example.test",
"enable": true,
"expiryTime": 0,
"limitIp": 0,
"reset": 0,
"subId": "hy2-001",
"tgId": 0,
"totalGB": 0,
},
],
"version": 2,
},
}
`;
exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
{
"protocol": "mixed",

View File

@@ -1,20 +0,0 @@
{
"protocol": "hysteria2",
"settings": {
"version": 2,
"clients": [
{
"auth": "hyst3ria2-auth-token-XYZ",
"email": "hy2-client@example.test",
"limitIp": 0,
"totalGB": 0,
"expiryTime": 0,
"enable": true,
"tgId": 0,
"subId": "hy2-001",
"comment": "",
"reset": 0
}
]
}
}

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import {
createDefaultHttpInboundSettings,
createDefaultHysteria2InboundSettings,
createDefaultHysteriaClient,
createDefaultHysteriaInboundSettings,
createDefaultMixedInboundSettings,
@@ -18,7 +17,6 @@ import {
createDefaultWireguardInboundSettings,
} from '@/lib/xray/inbound-defaults';
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
import { Hysteria2InboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria2';
import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria';
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
import { ShadowsocksClientSchema, ShadowsocksInboundSettingsSchema } from '@/schemas/protocols/inbound/shadowsocks';
@@ -112,12 +110,6 @@ describe('createDefault*InboundSettings factories', () => {
expect(HysteriaInboundSettingsSchema.parse(s)).toEqual(s);
});
it('hysteria2', () => {
const s = createDefaultHysteria2InboundSettings();
expect(s).toMatchSnapshot();
expect(Hysteria2InboundSettingsSchema.parse(s)).toEqual(s);
});
it('http', () => {
const s = createDefaultHttpInboundSettings();
expect(s).toMatchSnapshot();
@@ -139,6 +131,7 @@ describe('createDefault*InboundSettings factories', () => {
it('wireguard', () => {
const s = createDefaultWireguardInboundSettings({
secretKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
peerPrivateKey: 'cGVlci1maXh0dXJlLXByaXZhdGUta2V5LWZvci10ZXN0cw==',
});
expect(s).toMatchSnapshot();
expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);

View File

@@ -213,12 +213,6 @@ describe('genInboundLinks orchestrator', () => {
.sort(([a], [b]) => a.localeCompare(b));
for (const [name, raw] of fixtures) {
const protocol = (raw as { protocol?: string }).protocol;
// Skip hysteria2 — the legacy class had no dispatch case at the time
// the baseline was locked, so no snapshot exists. The new orchestrator
// covers it via its own logic and the genHysteriaLink unit test.
if (protocol === 'hysteria2') continue;
it(`${name}: byte-stable`, () => {
const typed = InboundSchema.parse(raw);
const block = genInboundLinks({

View File

@@ -5,7 +5,6 @@ import {
createDefaultDNSOutboundSettings,
createDefaultFreedomOutboundSettings,
createDefaultHttpOutboundSettings,
createDefaultHysteria2OutboundSettings,
createDefaultHysteriaOutboundSettings,
createDefaultLoopbackOutboundSettings,
createDefaultShadowsocksOutboundSettings,
@@ -21,7 +20,6 @@ import {
DNSOutboundSettingsSchema,
FreedomOutboundSettingsSchema,
HttpOutboundSettingsSchema,
Hysteria2OutboundSettingsSchema,
HysteriaOutboundSettingsSchema,
LoopbackOutboundSettingsSchema,
ShadowsocksOutboundSettingsSchema,
@@ -132,12 +130,6 @@ describe('outbound default factories: shape snapshots', () => {
address: '', port: 443, version: 2,
});
});
it('hysteria2 mirrors hysteria with literal version 2', () => {
expect(createDefaultHysteria2OutboundSettings()).toEqual({
address: '', port: 443, version: 2,
});
});
});
describe('outbound default factories: schema acceptance after stub fill-in', () => {
@@ -219,18 +211,12 @@ describe('outbound default factories: schema acceptance after stub fill-in', ()
def.address = SAMPLE_ADDRESS;
expect(HysteriaOutboundSettingsSchema.safeParse(def).success).toBe(true);
});
it('hysteria2 parses once address is filled', () => {
const def = createDefaultHysteria2OutboundSettings();
def.address = SAMPLE_ADDRESS;
expect(Hysteria2OutboundSettingsSchema.safeParse(def).success).toBe(true);
});
});
describe('createDefaultOutboundSettings dispatcher', () => {
const PROTOCOLS = [
'freedom', 'blackhole', 'dns', 'vmess', 'vless', 'trojan', 'shadowsocks',
'socks', 'http', 'wireguard', 'hysteria', 'hysteria2', 'loopback',
'socks', 'http', 'wireguard', 'hysteria', 'loopback',
];
for (const protocol of PROTOCOLS) {