Pārlūkot izejas kodu

feat(frontend): inbound Hysteria stream sub-form (auth + udpIdleTimeout + masquerade)

Restore the inbound side of Hysteria stream configuration that was
previously hidden — the legacy modal exposed these knobs but the
Pattern A rewrite gated them out.

schemas/protocols/stream/hysteria.ts:
- HysteriaMasqueradeSchema covers the inbound-only masquerade wire
  shape: type ('proxy'|'file'|'string'), dir, url, rewriteHost,
  insecure, content, headers, statusCode. The three masquerade types
  cover the spectrum: reverse-proxy upstream, serve static files, or
  return a fixed string body.
- HysteriaStreamSettingsSchema gains 3 inbound-side optional fields:
  protocol, udpIdleTimeout, masquerade. Outbound side is untouched
  (the legacy class accepted both wire shapes via the same struct).

InboundFormModal.tsx:
- New hysteria stream sub-form section in streamTab, gated by
  protocol === HYSTERIA. Fields: version (disabled, locked to 2),
  auth, udpIdleTimeout, masquerade Switch + nested type-Select with
  three conditional sub-blocks (proxy URL+rewriteHost+insecure,
  file dir, string statusCode+body+headers).
- onValuesChange cascade: switching TO hysteria seeds streamSettings
  with the hysteria branch (forcing network='hysteria' + TLS); switching
  AWAY from hysteria snaps back to TCP so the standard network
  selector has a valid starting point.

masquerade headers use the HeaderMapEditor v1 component.
MHSanaei 11 stundas atpakaļ
vecāks
revīzija
5c902ca298

+ 157 - 0
frontend/src/pages/inbounds/InboundFormModal.tsx

@@ -510,6 +510,28 @@ export default function InboundFormModal({
       if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
         form.setFieldValue('nodeId', null);
       }
+      // Hysteria uses its dedicated transport — force the network branch
+      // so the stream tab renders the hysteria sub-form, not the leftover
+      // tcpSettings from the previous protocol. When leaving hysteria,
+      // snap back to TCP so the standard network selector has a valid
+      // starting point.
+      if (next === Protocols.HYSTERIA) {
+        form.setFieldValue('streamSettings', {
+          network: 'hysteria',
+          security: 'tls',
+          hysteriaSettings: {
+            version: 2,
+            auth: '',
+            udpIdleTimeout: 60,
+          },
+          tlsSettings: {},
+        });
+      } else {
+        const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
+        if (current?.network === 'hysteria') {
+          form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
+        }
+      }
     }
   };
 
@@ -1198,6 +1220,141 @@ export default function InboundFormModal({
         </Form.Item>
       )}
 
+      {/* Inbound Hysteria stream sub-form. The transport for hysteria
+          isn't user-selectable (always 'hysteria'), so the network
+          dropdown is hidden above. Fields here mirror the legacy
+          HysteriaStreamSettings inbound class: version is locked to 2,
+          auth + udpIdleTimeout are required, masquerade is an optional
+          sub-object that lets xray-core disguise the listener as an
+          HTTP server when probed. */}
+      {protocol === Protocols.HYSTERIA && (
+        <>
+          <Form.Item
+            label="Version"
+            name={['streamSettings', 'hysteriaSettings', 'version']}
+          >
+            <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']}
+          >
+            <InputNumber min={1} style={{ width: '100%' }} />
+          </Form.Item>
+
+          <Form.Item label="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: 'proxy', 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="Type"
+                    name={['streamSettings', 'hysteriaSettings', 'masquerade', 'type']}
+                  >
+                    <Select
+                      options={[
+                        { 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="Upstream URL"
+                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'url']}
+                      >
+                        <Input placeholder="https://www.example.com" />
+                      </Form.Item>
+                      <Form.Item
+                        label="Rewrite Host"
+                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'rewriteHost']}
+                        valuePropName="checked"
+                      >
+                        <Switch />
+                      </Form.Item>
+                      <Form.Item
+                        label="Skip TLS verify"
+                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'insecure']}
+                        valuePropName="checked"
+                      >
+                        <Switch />
+                      </Form.Item>
+                    </>
+                  )}
+                  {m.type === 'file' && (
+                    <Form.Item
+                      label="Directory"
+                      name={['streamSettings', 'hysteriaSettings', 'masquerade', 'dir']}
+                    >
+                      <Input placeholder="/var/www/html" />
+                    </Form.Item>
+                  )}
+                  {m.type === 'string' && (
+                    <>
+                      <Form.Item
+                        label="Status code"
+                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'statusCode']}
+                      >
+                        <InputNumber min={0} max={599} style={{ width: '100%' }} />
+                      </Form.Item>
+                      <Form.Item
+                        label="Body"
+                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'content']}
+                      >
+                        <Input.TextArea autoSize={{ minRows: 3 }} />
+                      </Form.Item>
+                      <Form.Item
+                        label="Headers"
+                        name={['streamSettings', 'hysteriaSettings', 'masquerade', 'headers']}
+                      >
+                        <HeaderMapEditor mode="v1" />
+                      </Form.Item>
+                    </>
+                  )}
+                </>
+              );
+            }}
+          </Form.Item>
+        </>
+      )}
+
       {network === 'tcp' && (
         <>
           <Form.Item

+ 26 - 0
frontend/src/schemas/protocols/stream/hysteria.ts

@@ -17,7 +17,26 @@ export type HysteriaUdphop = z.infer<typeof HysteriaUdphopSchema>;
 // 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.
+export const HysteriaMasqueradeSchema = z.object({
+  type: z.enum(['proxy', 'file', 'string']).default('proxy'),
+  dir: z.string().default(''),
+  url: z.string().default(''),
+  rewriteHost: z.boolean().default(false),
+  insecure: z.boolean().default(false),
+  content: z.string().default(''),
+  headers: z.record(z.string(), z.string()).default({}),
+  statusCode: z.number().int().min(0).default(0),
+});
+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(''),
@@ -34,5 +53,12 @@ export const HysteriaStreamSettingsSchema = z.object({
   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(),
+  masquerade: HysteriaMasqueradeSchema.optional(),
 });
 export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;