ソースを参照

feat(frontend): Hysteria stream sub-form (schema branch + outbound UI)

Add the 7th branch to NetworkSettingsSchema for Hysteria transport.

schemas/protocols/stream/hysteria.ts:
- HysteriaStreamSettingsSchema covers the full wire shape: version=2,
  auth, congestion (''|'brutal'), up/down bandwidth strings, optional
  udphop sub-object for port-hopping, receive-window tuning fields,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery.

schemas/protocols/stream/index.ts:
- NetworkSchema gains 'hysteria'.
- NetworkSettingsSchema gains the 7th branch
  { network: 'hysteria', hysteriaSettings: HysteriaStreamSettingsSchema }.

OutboundFormModal.tsx:
- NETWORK_OPTIONS keeps the 6 standard transports for non-hysteria
  protocols; when protocol === 'hysteria', a 7th option is appended
  (matches the legacy [...NETWORKS, 'hysteria'] gate).
- newStreamSlice handles the 'hysteria' case with sensible defaults
  matching the legacy HysteriaStreamSettings constructor.
- New sub-form when network === 'hysteria': 8 common fields (auth,
  congestion, up, down, udphop Switch + 3 nested fields when on,
  maxIdleTimeout, keepAlivePeriod, disablePathMTUDiscovery).
- Receive-window tuning fields are still edit-via-JSON (rarely
  touched + would clutter the form).
MHSanaei 9 時間 前
コミット
19204f9e04

+ 147 - 1
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -99,6 +99,11 @@ 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`.
+const HYSTERIA_NETWORK_OPTION = { value: 'hysteria', label: 'Hysteria' };
+
 // Per-network bootstrap. Mirrors the legacy class constructors so the
 // initial state for each transport matches what xray-core expects.
 function newStreamSlice(network: string): Record<string, unknown> {
@@ -136,6 +141,24 @@ function newStreamSlice(network: string): Record<string, unknown> {
           xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
         },
       };
+    case 'hysteria':
+      return {
+        network: 'hysteria',
+        hysteriaSettings: {
+          version: 2,
+          auth: '',
+          congestion: '',
+          up: '0',
+          down: '0',
+          initStreamReceiveWindow: 8388608,
+          maxStreamReceiveWindow: 8388608,
+          initConnectionReceiveWindow: 20971520,
+          maxConnectionReceiveWindow: 20971520,
+          maxIdleTimeout: 30,
+          keepAlivePeriod: 2,
+          disablePathMTUDiscovery: false,
+        },
+      };
     default:
       return { network: 'tcp', tcpSettings: { header: { type: 'none' } } };
   }
@@ -1053,7 +1076,11 @@ export default function OutboundFormModal({
                           <Select
                             value={network}
                             onChange={onNetworkChange}
-                            options={NETWORK_OPTIONS}
+                            options={
+                              protocol === 'hysteria'
+                                ? [...NETWORK_OPTIONS, HYSTERIA_NETWORK_OPTION]
+                                : NETWORK_OPTIONS
+                            }
                           />
                         </Form.Item>
 
@@ -1286,6 +1313,125 @@ export default function OutboundFormModal({
                             </div>
                           </>
                         )}
+
+                        {network === 'hysteria' && (
+                          <>
+                            <Form.Item
+                              label="Auth password"
+                              name={['streamSettings', 'hysteriaSettings', 'auth']}
+                            >
+                              <Input />
+                            </Form.Item>
+                            <Form.Item
+                              label="Congestion"
+                              name={['streamSettings', 'hysteriaSettings', 'congestion']}
+                            >
+                              <Select
+                                options={[
+                                  { value: '', label: 'BBR (auto)' },
+                                  { value: 'brutal', label: 'Brutal' },
+                                ]}
+                              />
+                            </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>
+                          </>
+                        )}
                       </>
                     )}
 

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

@@ -0,0 +1,38 @@
+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.
+
+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')]);
+
+export const HysteriaStreamSettingsSchema = z.object({
+  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),
+});
+export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;

+ 10 - 1
frontend/src/schemas/protocols/stream/index.ts

@@ -4,6 +4,7 @@ import { ExternalProxyEntrySchema } from './external-proxy';
 import { FinalMaskStreamSettingsSchema } from './finalmask';
 import { GrpcStreamSettingsSchema } from './grpc';
 import { HttpUpgradeStreamSettingsSchema } from './httpupgrade';
+import { HysteriaStreamSettingsSchema } from './hysteria';
 import { KcpStreamSettingsSchema } from './kcp';
 import { SockoptStreamSettingsSchema } from './sockopt';
 import { TcpStreamSettingsSchema } from './tcp';
@@ -14,13 +15,16 @@ export * from './external-proxy';
 export * from './finalmask';
 export * from './grpc';
 export * from './httpupgrade';
+export * from './hysteria';
 export * from './kcp';
 export * from './sockopt';
 export * from './tcp';
 export * from './ws';
 export * from './xhttp';
 
-export const NetworkSchema = z.enum(['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp']);
+export const NetworkSchema = z.enum([
+  'tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp', 'hysteria',
+]);
 export type Network = z.infer<typeof NetworkSchema>;
 
 // Tagged-wrapper DU on `network`. The wire shape uses an asymmetric per-
@@ -28,6 +32,10 @@ export type Network = z.infer<typeof NetworkSchema>;
 // `settings` object — same pattern Xray ships and the panel's StreamSettings
 // class flattens via toJson. Each branch carries only the matching key so
 // fixtures round-trip byte-identical.
+//
+// `hysteria` is only valid when the parent protocol is hysteria — the
+// network selector hides it for other protocols. xray-core enforces
+// the constraint server-side too.
 export const NetworkSettingsSchema = z.discriminatedUnion('network', [
   z.object({ network: z.literal('tcp'),         tcpSettings:         TcpStreamSettingsSchema }),
   z.object({ network: z.literal('kcp'),         kcpSettings:         KcpStreamSettingsSchema }),
@@ -35,6 +43,7 @@ export const NetworkSettingsSchema = z.discriminatedUnion('network', [
   z.object({ network: z.literal('grpc'),        grpcSettings:        GrpcStreamSettingsSchema }),
   z.object({ network: z.literal('httpupgrade'), httpupgradeSettings: HttpUpgradeStreamSettingsSchema }),
   z.object({ network: z.literal('xhttp'),       xhttpSettings:       XHttpStreamSettingsSchema }),
+  z.object({ network: z.literal('hysteria'),    hysteriaSettings:    HysteriaStreamSettingsSchema }),
 ]);
 export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;