From eee26e47880b07c95009046982ef783bf50c7b23 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 29 May 2026 23:56:27 +0200 Subject: [PATCH] fix(outbounds): lock hysteria to its QUIC transport + TLS, add version/masquerade The hysteria protocol now offers only the Hysteria transport (other transports removed) and security is always TLS. This prevents the broken hysteria-over-tcp / security:none outbounds that made xray-core fail to start with 'Failed to build Hysteria config. > version != 2'. Show the fixed version field directly under Transmission, and expose the full masquerade sub-form on the outbound too. The masquerade UI was extracted into a shared HysteriaMasqueradeForm component used by both the inbound and outbound forms. Closes #4665 --- .../src/components/HysteriaMasqueradeForm.tsx | 120 ++++++++++++++++++ .../src/pages/inbounds/InboundFormModal.tsx | 107 +--------------- frontend/src/pages/xray/OutboundFormModal.tsx | 83 ++++++------ 3 files changed, 166 insertions(+), 144 deletions(-) create mode 100644 frontend/src/components/HysteriaMasqueradeForm.tsx 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 === '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 === '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}