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

feat(frontend): HeaderMapEditor reusable component + wire WS/HTTPUpgrade headers

Add a single reusable header-map editor that handles the two wire
shapes Xray uses:

- v1: { name: 'value' } — used by WS / HTTPUpgrade / Hysteria
  masquerade. One value per name.
- v2: { name: ['value1', 'value2'] } — used by TCP HTTP camouflage.
  Each header can repeat (RFC 7230 §3.2.2).

Internal state is always a flat list of {name, value} rows regardless
of mode; conversion to/from the wire shape happens at the value /
onChange boundary so consumers bind straight to a Form.Item with no
extra transforms.

Wired into:
- InboundFormModal: WS Headers, HTTPUpgrade Headers
- OutboundFormModal: WS Headers, HTTPUpgrade Headers

XHTTP headers are already in a list-of-rows wire shape (different
from these two), so they keep their bespoke editor. Hysteria
masquerade is still deferred until the Hysteria stream sub-form
lands.
MHSanaei 8 часов назад
Родитель
Сommit
7442486a58

+ 122 - 0
frontend/src/components/HeaderMapEditor.tsx

@@ -0,0 +1,122 @@
+import { useMemo } from 'react';
+import { Button, Input, Space } from 'antd';
+import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+
+import InputAddon from '@/components/InputAddon';
+
+// Reusable header-map editor. Handles the two wire shapes Xray uses for
+// HTTP-style header maps:
+//
+//   v1:   { 'Content-Type': 'application/json',  'X-Custom': 'value' }
+//         Used by WS / HTTPUpgrade / Hysteria masquerade. One value per
+//         name.
+//
+//   v2:   { 'Accept':       ['text/html', 'application/json'],
+//           'X-Forwarded':  ['1.2.3.4'] }
+//         Used by TCP HTTP camouflage request/response. Each header can
+//         repeat (RFC 7230 §3.2.2).
+//
+// Internal state is always the flat list-of-rows shape regardless of
+// mode. Conversion to/from the wire shape happens at the value/onChange
+// boundary so consumers can bind straight to a Form.Item without any
+// extra transforms on their side.
+
+export type HeaderMapMode = 'v1' | 'v2';
+
+export type HeaderMapValue =
+  | Record<string, string>
+  | Record<string, string[]>
+  | undefined;
+
+interface HeaderRow {
+  name: string;
+  value: string;
+}
+
+interface HeaderMapEditorProps {
+  mode: HeaderMapMode;
+  value?: HeaderMapValue;
+  onChange?: (next: Record<string, string> | Record<string, string[]>) => void;
+}
+
+function mapToRows(value: HeaderMapValue): HeaderRow[] {
+  if (!value || typeof value !== 'object') return [];
+  const out: HeaderRow[] = [];
+  for (const [name, raw] of Object.entries(value)) {
+    if (Array.isArray(raw)) {
+      for (const v of raw) {
+        out.push({ name, value: typeof v === 'string' ? v : String(v) });
+      }
+    } else if (typeof raw === 'string') {
+      out.push({ name, value: raw });
+    }
+  }
+  return out;
+}
+
+function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, string> | Record<string, string[]> {
+  if (mode === 'v1') {
+    const map: Record<string, string> = {};
+    for (const r of rows) {
+      if (!r.name) continue;
+      map[r.name] = r.value ?? '';
+    }
+    return map;
+  }
+  const map: Record<string, string[]> = {};
+  for (const r of rows) {
+    if (!r.name) continue;
+    const list = map[r.name] ?? [];
+    list.push(r.value ?? '');
+    map[r.name] = list;
+  }
+  return map;
+}
+
+export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
+  const rows = useMemo(() => mapToRows(value), [value]);
+
+  function commit(next: HeaderRow[]) {
+    onChange?.(rowsToMap(next, mode));
+  }
+
+  function setRow(index: number, patch: Partial<HeaderRow>) {
+    const next = rows.slice();
+    next[index] = { ...next[index], ...patch };
+    commit(next);
+  }
+
+  function addRow() {
+    commit([...rows, { name: '', value: '' }]);
+  }
+
+  function removeRow(index: number) {
+    const next = rows.slice();
+    next.splice(index, 1);
+    commit(next);
+  }
+
+  return (
+    <>
+      {rows.map((row, idx) => (
+        <Space.Compact key={idx} block className="mb-8">
+          <InputAddon>{`${idx + 1}`}</InputAddon>
+          <Input
+            value={row.name}
+            placeholder="Name"
+            onChange={(e) => setRow(idx, { name: e.target.value })}
+          />
+          <Input
+            value={row.value}
+            placeholder="Value"
+            onChange={(e) => setRow(idx, { value: e.target.value })}
+          />
+          <Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
+        </Space.Compact>
+      ))}
+      <Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
+        Add
+      </Button>
+    </>
+  );
+}

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

@@ -57,6 +57,7 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'
 import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
 import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
 import DateTimePicker from '@/components/DateTimePicker';
+import HeaderMapEditor from '@/components/HeaderMapEditor';
 import InputAddon from '@/components/InputAddon';
 import JsonEditor from '@/components/JsonEditor';
 import type { FormInstance } from 'antd';
@@ -1255,6 +1256,12 @@ export default function InboundFormModal({
           >
             <InputNumber min={0} />
           </Form.Item>
+          <Form.Item
+            label="Headers"
+            name={['streamSettings', 'wsSettings', 'headers']}
+          >
+            <HeaderMapEditor mode="v1" />
+          </Form.Item>
         </>
       )}
 
@@ -1486,6 +1493,12 @@ export default function InboundFormModal({
           >
             <Input />
           </Form.Item>
+          <Form.Item
+            label="Headers"
+            name={['streamSettings', 'httpupgradeSettings', 'headers']}
+          >
+            <HeaderMapEditor mode="v1" />
+          </Form.Item>
         </>
       )}
 

+ 13 - 0
frontend/src/pages/xray/OutboundFormModal.tsx

@@ -15,6 +15,7 @@ import {
 } from 'antd';
 import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons';
 
+import HeaderMapEditor from '@/components/HeaderMapEditor';
 import InputAddon from '@/components/InputAddon';
 import JsonEditor from '@/components/JsonEditor';
 import { Wireguard } from '@/utils';
@@ -1196,6 +1197,12 @@ export default function OutboundFormModal({
                             >
                               <InputNumber min={0} />
                             </Form.Item>
+                            <Form.Item
+                              label="Headers"
+                              name={['streamSettings', 'wsSettings', 'headers']}
+                            >
+                              <HeaderMapEditor mode="v1" />
+                            </Form.Item>
                           </>
                         )}
 
@@ -1237,6 +1244,12 @@ export default function OutboundFormModal({
                             >
                               <Input />
                             </Form.Item>
+                            <Form.Item
+                              label="Headers"
+                              name={['streamSettings', 'httpupgradeSettings', 'headers']}
+                            >
+                              <HeaderMapEditor mode="v1" />
+                            </Form.Item>
                           </>
                         )}