Просмотр исходного кода

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
MHSanaei 17 часов назад
Родитель
Сommit
eee26e4788

+ 120 - 0
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 (
+    <>
+      <Form.Item label={t('pages.inbounds.form.masquerade')}>
+        <Form.Item shouldUpdate noStyle>
+          {() => {
+            const m = form.getFieldValue(MASQ_PATH);
+            return (
+              <Switch
+                checked={!!m}
+                onChange={(checked) =>
+                  form.setFieldValue(
+                    MASQ_PATH,
+                    checked
+                      ? {
+                        type: '', dir: '', url: '',
+                        rewriteHost: false, insecure: false,
+                        content: '', headers: {}, statusCode: 0,
+                      }
+                      : undefined,
+                  )
+                }
+              />
+            );
+          }}
+        </Form.Item>
+      </Form.Item>
+      <Form.Item shouldUpdate noStyle>
+        {() => {
+          const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
+          if (!m) return null;
+          return (
+            <>
+              <Form.Item
+                label={t('pages.inbounds.form.type')}
+                name={[...MASQ_PATH, 'type']}
+              >
+                <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)' },
+                  ]}
+                />
+              </Form.Item>
+              {m.type === 'proxy' && (
+                <>
+                  <Form.Item
+                    label={t('pages.inbounds.form.upstreamUrl')}
+                    name={[...MASQ_PATH, 'url']}
+                  >
+                    <Input placeholder="https://www.example.com" />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.rewriteHost')}
+                    name={[...MASQ_PATH, 'rewriteHost']}
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.skipTlsVerify')}
+                    name={[...MASQ_PATH, 'insecure']}
+                    valuePropName="checked"
+                  >
+                    <Switch />
+                  </Form.Item>
+                </>
+              )}
+              {m.type === 'file' && (
+                <Form.Item
+                  label={t('pages.inbounds.form.directory')}
+                  name={[...MASQ_PATH, 'dir']}
+                >
+                  <Input placeholder="/var/www/html" />
+                </Form.Item>
+              )}
+              {m.type === 'string' && (
+                <>
+                  <Form.Item
+                    label={t('pages.inbounds.form.statusCode')}
+                    name={[...MASQ_PATH, 'statusCode']}
+                  >
+                    <InputNumber min={0} max={599} style={{ width: '100%' }} />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.body')}
+                    name={[...MASQ_PATH, 'content']}
+                  >
+                    <Input.TextArea autoSize={{ minRows: 3 }} />
+                  </Form.Item>
+                  <Form.Item
+                    label={t('pages.inbounds.form.headers')}
+                    name={[...MASQ_PATH, 'headers']}
+                  >
+                    <HeaderMapEditor mode="v1" />
+                  </Form.Item>
+                </>
+              )}
+            </>
+          );
+        }}
+      </Form.Item>
+    </>
+  );
+}

+ 2 - 105
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({
             <InputNumber min={1} style={{ width: '100%' }} />
           </Form.Item>
 
-          <Form.Item label={t('pages.inbounds.form.masquerade')}>
-            <Form.Item shouldUpdate noStyle>
-              {() => {
-                const m = form.getFieldValue([
-                  'streamSettings', 'hysteriaSettings', 'masquerade',
-                ]);
-                return (
-                  <Switch
-                    checked={!!m}
-                    onChange={(checked) =>
-                      form.setFieldValue(
-                        ['streamSettings', 'hysteriaSettings', 'masquerade'],
-                        checked
-                          ? {
-                            type: '', dir: '', url: '',
-                            rewriteHost: false, insecure: false,
-                            content: '', headers: {}, statusCode: 0,
-                          }
-                          : undefined,
-                      )
-                    }
-                  />
-                );
-              }}
-            </Form.Item>
-          </Form.Item>
-          <Form.Item shouldUpdate noStyle>
-            {() => {
-              const m = form.getFieldValue([
-                'streamSettings', 'hysteriaSettings', 'masquerade',
-              ]) as { type?: string } | undefined;
-              if (!m) return null;
-              return (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.type')}
-                    name={['streamSettings', 'hysteriaSettings', 'masquerade', 'type']}
-                  >
-                    <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)' },
-                      ]}
-                    />
-                  </Form.Item>
-                  {m.type === 'proxy' && (
-                    <>
-                      <Form.Item
-                        label={t('pages.inbounds.form.upstreamUrl')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'url']}
-                      >
-                        <Input placeholder="https://www.example.com" />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.rewriteHost')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'rewriteHost']}
-                        valuePropName="checked"
-                      >
-                        <Switch />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.skipTlsVerify')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'insecure']}
-                        valuePropName="checked"
-                      >
-                        <Switch />
-                      </Form.Item>
-                    </>
-                  )}
-                  {m.type === 'file' && (
-                    <Form.Item
-                      label={t('pages.inbounds.form.directory')}
-                      name={['streamSettings', 'hysteriaSettings', 'masquerade', 'dir']}
-                    >
-                      <Input placeholder="/var/www/html" />
-                    </Form.Item>
-                  )}
-                  {m.type === 'string' && (
-                    <>
-                      <Form.Item
-                        label={t('pages.inbounds.form.statusCode')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'statusCode']}
-                      >
-                        <InputNumber min={0} max={599} style={{ width: '100%' }} />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.body')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'content']}
-                      >
-                        <Input.TextArea autoSize={{ minRows: 3 }} />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.inbounds.form.headers')}
-                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'headers']}
-                      >
-                        <HeaderMapEditor mode="v1" />
-                      </Form.Item>
-                    </>
-                  )}
-                </>
-              );
-            }}
-          </Form.Item>
+          <HysteriaMasqueradeForm form={form} />
         </>
       )}
 

+ 44 - 39
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<string, unknown> {
   }
 }
 
+// Hysteria2 always rides its own QUIC transport with TLS — the panel never
+// offers another transport or 'none' security for it.
+function hysteriaStreamSlice(): Record<string, unknown> {
+  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<string, unknown>;
+    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<OutboundFormValues>) {
     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<string, unknown>;
@@ -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' && (
-                      <Form.Item label={t('pages.inbounds.form.version')} name={['settings', 'version']}>
-                        <InputNumber min={2} max={2} disabled />
-                      </Form.Item>
-                    )}
-
                     {protocol === 'loopback' && (
                       <Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
                         <Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
@@ -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' && (
                           <>
+                            <Form.Item
+                              label={t('pages.inbounds.form.version')}
+                              name={['streamSettings', 'hysteriaSettings', 'version']}
+                            >
+                              <InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
+                            </Form.Item>
                             <Form.Item
                               label={t('pages.xray.outboundForm.authPassword')}
                               name={['streamSettings', 'hysteriaSettings', 'auth']}
@@ -1733,6 +1737,7 @@ export default function OutboundFormModal({
                             >
                               <InputNumber min={1} style={{ width: '100%' }} />
                             </Form.Item>
+                            <HysteriaMasqueradeForm form={form} />
                           </>
                         )}
                       </>
@@ -1783,7 +1788,7 @@ export default function OutboundFormModal({
                           buttonStyle="solid"
                           onChange={(e) => onSecurityChange(e.target.value as string)}
                         >
-                          <Radio.Button value="none">{t('none')}</Radio.Button>
+                          {network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
                           {tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
                           {realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
                         </Radio.Group>