diff --git a/frontend/src/components/HysteriaMasqueradeForm.tsx b/frontend/src/components/HysteriaMasqueradeForm.tsx
new file mode 100644
index 00000000..788ba4bc
--- /dev/null
+++ b/frontend/src/components/HysteriaMasqueradeForm.tsx
@@ -0,0 +1,120 @@
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import type { FormInstance } from 'antd';
+
+import HeaderMapEditor from '@/components/HeaderMapEditor';
+
+const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
+
+interface HysteriaMasqueradeFormProps {
+ form: FormInstance;
+}
+
+export default function HysteriaMasqueradeForm({ form }: HysteriaMasqueradeFormProps) {
+ const { t } = useTranslation();
+ return (
+ <>
+
+
+ {() => {
+ const m = form.getFieldValue(MASQ_PATH);
+ return (
+
+ form.setFieldValue(
+ MASQ_PATH,
+ checked
+ ? {
+ type: '', dir: '', url: '',
+ rewriteHost: false, insecure: false,
+ content: '', headers: {}, statusCode: 0,
+ }
+ : undefined,
+ )
+ }
+ />
+ );
+ }}
+
+
+
+ {() => {
+ const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
+ if (!m) return null;
+ return (
+ <>
+
+
+
+ {m.type === 'proxy' && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {m.type === 'file' && (
+
+
+
+ )}
+ {m.type === 'string' && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ );
+ }}
+
+ >
+ );
+}
diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx
index 2dc726cc..609717bf 100644
--- a/frontend/src/pages/inbounds/InboundFormModal.tsx
+++ b/frontend/src/pages/inbounds/InboundFormModal.tsx
@@ -83,6 +83,7 @@ import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
import DateTimePicker from '@/components/DateTimePicker';
import FinalMaskForm from '@/components/FinalMaskForm';
import HeaderMapEditor from '@/components/HeaderMapEditor';
+import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm';
import InputAddon from '@/components/InputAddon';
import JsonEditor from '@/components/JsonEditor';
import './InboundFormModal.css';
@@ -1606,111 +1607,7 @@ export default function InboundFormModal({
-
-
- {() => {
- const m = form.getFieldValue([
- 'streamSettings', 'hysteriaSettings', 'masquerade',
- ]);
- return (
-
- form.setFieldValue(
- ['streamSettings', 'hysteriaSettings', 'masquerade'],
- checked
- ? {
- type: '', 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 === 'proxy' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- {m.type === 'file' && (
-
-
-
- )}
- {m.type === 'string' && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- >
- );
- }}
-
+
>
)}
diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx
index 1e1d0dde..cf384887 100644
--- a/frontend/src/pages/xray/OutboundFormModal.tsx
+++ b/frontend/src/pages/xray/OutboundFormModal.tsx
@@ -17,6 +17,7 @@ import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@an
import FinalMaskForm from '@/components/FinalMaskForm';
import HeaderMapEditor from '@/components/HeaderMapEditor';
+import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm';
import InputAddon from '@/components/InputAddon';
import JsonEditor from '@/components/JsonEditor';
import { Wireguard } from '@/utils';
@@ -107,9 +108,8 @@ 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`.
+// The hysteria protocol is locked to its own QUIC transport: the selector
+// shows only this option when the parent protocol is hysteria.
const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
// Per-network bootstrap. Mirrors the legacy class constructors so the
@@ -163,6 +163,19 @@ function newStreamSlice(network: string): Record {
}
}
+// Hysteria2 always rides its own QUIC transport with TLS — the panel never
+// offers another transport or 'none' security for it.
+function hysteriaStreamSlice(): Record {
+ return {
+ ...newStreamSlice('hysteria'),
+ security: 'tls',
+ tlsSettings: {
+ serverName: '', alpn: ['h3'], fingerprint: '',
+ echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
+ },
+ };
+}
+
// Protocols whose form schema carries a flat connect target — these all
// get the shared "server" sub-block (address + port) at the top of the
// protocol section. Wireguard has an address but no port. DNS/freedom/
@@ -233,23 +246,13 @@ export default function OutboundFormModal({
const tag = Form.useWatch('tag', form) ?? '';
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
- // preserve: true — without it useWatch only reflects values whose
- // Form.Item is currently mounted. The streamSettings selectors live
- // INSIDE `{streamAllowed && network && (...)}`, so the moment that
- // conditional gates them out, useWatch returns undefined, the gate
- // keeps returning false, and the stream block never renders even
- // though streamSettings is in the form store.
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
-
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
- // Seed streamSettings when the user picks a protocol that supports
- // streams but the form does not yet have a stream slice (new outbound,
- // or wire payload arrived without streamSettings).
useEffect(() => {
if (!streamAllowed) return;
if (network) return;
@@ -257,9 +260,16 @@ export default function OutboundFormModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streamAllowed, network]);
- // Wireguard pubKey is a UI-only field derived from secretKey on every
- // edit. The legacy modal did the same on every keystroke. We re-derive
- // here so paste-in secret keys immediately surface the matching pub.
+ useEffect(() => {
+ if (protocol !== 'hysteria') return;
+ if (network === 'hysteria' && security === 'tls') return;
+ const existing = (form.getFieldValue('streamSettings') ?? {}) as Record;
+ const slice = hysteriaStreamSlice();
+ if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
+ if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
+ form.setFieldValue('streamSettings', slice);
+ }, [protocol, network, security]);
+
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
useEffect(() => {
if (protocol !== 'wireguard') return;
@@ -277,21 +287,18 @@ export default function OutboundFormModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [protocol, wgSecretKey]);
- // Switching protocol resets the settings sub-object to fresh defaults
- // so leftover fields from the previous protocol do not bleed through.
- // The adapter's rawOutboundToFormValues seeds whatever the new protocol
- // expects (vless flat shape, vmess flat shape, wireguard with secretKey
- // placeholder, etc.).
function onValuesChange(changed: Partial) {
if ('protocol' in changed && changed.protocol) {
const next = rawOutboundToFormValues({ protocol: changed.protocol });
form.setFieldValue('settings', next.settings);
+ if (changed.protocol === 'hysteria') {
+ form.setFieldValue('streamSettings', hysteriaStreamSlice());
+ } else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
+ form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
+ }
}
}
- // Security change cascade: swap the security sub-key so the DU branch
- // matches. Seed default field values when entering tls/reality so the
- // sub-forms render without `undefined` field references.
function onSecurityChange(next: string) {
const stream = form.getFieldValue('streamSettings') ?? {};
const cleaned = { ...stream } as Record;
@@ -324,6 +331,10 @@ export default function OutboundFormModal({
// wsSettings, etc.) so the DU branch matches. Preserve security if
// the new network supports it, otherwise force back to 'none'.
function onNetworkChange(next: string) {
+ if (next === 'hysteria') {
+ form.setFieldValue('streamSettings', hysteriaStreamSlice());
+ return;
+ }
const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
@@ -372,13 +383,6 @@ export default function OutboundFormModal({
return true;
}
- // Wrap every tab switch with a blur of the active element. AntD marks
- // the outgoing panel `aria-hidden="true"` synchronously when the
- // controlled activeKey flips; if a focused input is still inside that
- // panel (e.g. Input.Search on the JSON tab after user hits Enter to
- // import), Chrome logs a WAI-ARIA warning. Doing the blur right
- // before setActiveKey ensures the panel is unfocused by the time
- // AntD applies the attribute.
function switchTab(key: string) {
if (typeof document !== 'undefined') {
(document.activeElement as HTMLElement | null)?.blur?.();
@@ -597,12 +601,6 @@ export default function OutboundFormModal({
>
)}
- {protocol === 'hysteria' && (
-
-
-
- )}
-
{protocol === 'loopback' && (
@@ -1155,7 +1153,7 @@ export default function OutboundFormModal({
onChange={onNetworkChange}
options={
protocol === 'hysteria'
- ? [...NETWORK_OPTIONS, HYSTERIA_NETWORK_OPTION]
+ ? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
@@ -1721,6 +1719,12 @@ export default function OutboundFormModal({
{network === 'hysteria' && (
<>
+
+
+
+
>
)}
>
@@ -1783,7 +1788,7 @@ export default function OutboundFormModal({
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
- {t('none')}
+ {network !== 'hysteria' && {t('none')}}
{tlsAllowed && TLS}
{realityAllowed && Reality}